You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/13 07:36:36 UTC

(texera) 01/01: test(frontend): add unit test coverage for UserComputingUnitListItemComponent (#6391)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6391-fd77b32bfc6156a95c88aa07c3cd05cdb13b540d
in repository https://gitbox.apache.org/repos/asf/texera.git

commit b4b1ecb07c71c6755712532c6b72afdf73932cec
Author: Meng Wang <me...@uci.edu>
AuthorDate: Mon Jul 13 00:36:29 2026 -0700

    test(frontend): add unit test coverage for UserComputingUnitListItemComponent (#6391)
    
    ### What changes were proposed in this PR?
    
    Adds a Vitest spec for `UserComputingUnitListItemComponent`
    
    (`frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/`),
    which had no spec (codecov ~9%). The spec follows `frontend/TESTING.md`
    (TestBed +
    `fixture.detectChanges()` for template coverage) and mirrors the sibling
    `user-computing-unit.component.spec.ts` /
    `user-dataset-list-item.component.spec.ts`
    mocking conventions.
    
    20 behavior-focused tests:
    
    - **create / `ngOnInit`** — renders the template; populates `gpuOptions`
    from
      `getComputingUnitLimitOptions`, and notifies on that request failing.
    - **`entry` / `unit` getters** — throw before `entry` is set / when the
    entry has
      no computing unit; return the bound values otherwise.
    - **`startEditingUnitName`** — rejects non-owners with an error; enters
    edit mode
      for owners.
    - **`cancelEditingUnitName`** — clears the editing state.
    - **`confirmUpdateUnitName`** — rejects an empty name without calling
    the endpoint;
    on success renames with the *trimmed* name, updates
    `entry.computingUnit.name`
    (only when the cuid matches), refreshes the list, and resets editing; on
    failure
      notifies, leaves the name unchanged, and still resets editing.
    - **`onClickOpenShareAccess`** — delegates to
    `ComputingUnitActionsService.openShareAccessModal(cuid, false)`.
    - **`deleted` output** — is an `EventEmitter` and fires when the delete
    button is clicked.
    - **resource getters** — read limits straight from the unit and return
    zero usage
      values when metrics are unavailable.
    
    No production code was changed.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6389
    
    ### How was this PR tested?
    
    New Vitest spec, run locally in `frontend/` (all green; failure path
    verified by
    deliberately breaking an assertion to confirm the suite goes red):
    
    ```
    ng test --watch=false --include src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.spec.ts
    # Test Files 1 passed (1) / Tests 20 passed (20)
    eslint <spec>      # clean
    prettier --check   # clean
    ```
    
    Services (`WorkflowComputingUnitManagingService`, `NotificationService`,
    `ComputingUnitStatusService`, `ComputingUnitActionsService`) are mocked,
    so the
    spec makes no network calls and touches no live services.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 ...user-computing-unit-list-item.component.spec.ts | 292 +++++++++++++++++++++
 1 file changed, 292 insertions(+)

diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.spec.ts
new file mode 100644
index 0000000000..ed8971229e
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.spec.ts
@@ -0,0 +1,292 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { EventEmitter } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { By } from "@angular/platform-browser";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { of, throwError } from "rxjs";
+import type { Mocked } from "vitest";
+import { UserComputingUnitListItemComponent } from "./user-computing-unit-list-item.component";
+import { NotificationService } from "../../../../../common/service/notification/notification.service";
+import { WorkflowComputingUnitManagingService } from "../../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
+import { ComputingUnitStatusService } from "../../../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
+import { MockComputingUnitStatusService } from "../../../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service";
+import { ComputingUnitActionsService } from "../../../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service";
+import { DashboardWorkflowComputingUnit } from "../../../../../common/type/workflow-computing-unit";
+import { commonTestProviders } from "../../../../../common/testing/test-utils";
+
+function makeEntry(overrides: Partial<DashboardWorkflowComputingUnit> = {}): DashboardWorkflowComputingUnit {
+  return {
+    computingUnit: {
+      cuid: 1,
+      uid: 1,
+      name: "unit-1",
+      creationTime: 0,
+      terminateTime: undefined,
+      type: "local",
+      uri: "",
+      resource: {
+        cpuLimit: "1",
+        memoryLimit: "1Gi",
+        gpuLimit: "0",
+        jvmMemorySize: "0",
+        shmSize: "0",
+        nodeAddresses: [],
+      },
+    },
+    status: "Running",
+    metrics: { cpuUsage: "N/A", memoryUsage: "N/A" },
+    isOwner: true,
+    accessPrivilege: "WRITE",
+    ownerGoogleAvatar: "",
+    ownerName: "owner",
+    ...overrides,
+  };
+}
+
+describe("UserComputingUnitListItemComponent", () => {
+  let fixture: ComponentFixture<UserComputingUnitListItemComponent>;
+  let component: UserComputingUnitListItemComponent;
+  let computingUnitService: Mocked<WorkflowComputingUnitManagingService>;
+  let notificationService: Mocked<NotificationService>;
+  let actionsService: Mocked<ComputingUnitActionsService>;
+  let statusService: ComputingUnitStatusService;
+
+  beforeEach(async () => {
+    const computingUnitServiceSpy = {
+      getComputingUnitLimitOptions: vi.fn(),
+      renameComputingUnit: vi.fn(),
+    } as unknown as Mocked<WorkflowComputingUnitManagingService>;
+    computingUnitServiceSpy.getComputingUnitLimitOptions.mockReturnValue(
+      of({ cpuLimitOptions: [], memoryLimitOptions: [], gpuLimitOptions: ["0", "1"] })
+    );
+
+    const notificationServiceSpy = {
+      success: vi.fn(),
+      error: vi.fn(),
+      info: vi.fn(),
+      warning: vi.fn(),
+      loading: vi.fn(),
+      blank: vi.fn(),
+    };
+    const actionsServiceSpy = { openShareAccessModal: vi.fn() };
+
+    await TestBed.configureTestingModule({
+      imports: [UserComputingUnitListItemComponent, HttpClientTestingModule],
+      providers: [
+        NzModalService,
+        { provide: NotificationService, useValue: notificationServiceSpy },
+        { provide: WorkflowComputingUnitManagingService, useValue: computingUnitServiceSpy },
+        { provide: ComputingUnitStatusService, useClass: MockComputingUnitStatusService },
+        { provide: ComputingUnitActionsService, useValue: actionsServiceSpy },
+        ...commonTestProviders,
+      ],
+    }).compileComponents();
+
+    computingUnitService = TestBed.inject(
+      WorkflowComputingUnitManagingService
+    ) as unknown as Mocked<WorkflowComputingUnitManagingService>;
+    notificationService = TestBed.inject(NotificationService) as unknown as Mocked<NotificationService>;
+    actionsService = TestBed.inject(ComputingUnitActionsService) as unknown as Mocked<ComputingUnitActionsService>;
+    statusService = TestBed.inject(ComputingUnitStatusService);
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(UserComputingUnitListItemComponent);
+    component = fixture.componentInstance;
+    component.entry = makeEntry();
+    fixture.detectChanges();
+  });
+
+  it("should create", () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe("ngOnInit", () => {
+    it("populates gpuOptions from the service", () => {
+      expect(component.gpuOptions).toEqual(["0", "1"]);
+    });
+
+    it("notifies the user when the resource options request fails", () => {
+      computingUnitService.getComputingUnitLimitOptions.mockReturnValue(throwError(() => new Error("boom")));
+      const freshFixture = TestBed.createComponent(UserComputingUnitListItemComponent);
+      freshFixture.componentInstance.entry = makeEntry();
+      freshFixture.detectChanges();
+      expect(notificationService.error).toHaveBeenCalledWith(
+        expect.stringContaining("Failed to fetch resource options")
+      );
+    });
+  });
+
+  describe("entry and unit getters", () => {
+    function bare(): UserComputingUnitListItemComponent {
+      return new UserComputingUnitListItemComponent(
+        {} as any,
+        {} as any,
+        {} as any,
+        {} as any,
+        {} as any,
+        {} as any,
+        {} as any
+      );
+    }
+
+    it("throws when entry is accessed before being set", () => {
+      expect(() => bare().entry).toThrowError("entry property must be provided to UserComputingUnitListItemComponent.");
+    });
+
+    it("returns the entry once set", () => {
+      const entry = makeEntry();
+      const item = bare();
+      item.entry = entry;
+      expect(item.entry).toBe(entry);
+    });
+
+    it("exposes the computing unit via the unit getter", () => {
+      expect(component.unit).toBe(component.entry.computingUnit);
+    });
+
+    it("throws when the entry has no computing unit", () => {
+      const item = bare();
+      item.entry = {
+        ...makeEntry(),
+        computingUnit: undefined as unknown as DashboardWorkflowComputingUnit["computingUnit"],
+      };
+      expect(() => item.unit).toThrowError(
+        "Incorrect type of DashboardEntry provided to UserComputingUnitListItemComponent. Entry must be computing unit."
+      );
+    });
+  });
+
+  describe("startEditingUnitName", () => {
+    it("rejects renaming for non-owners", () => {
+      const entry = makeEntry({ isOwner: false });
+      component.startEditingUnitName(entry);
+      expect(notificationService.error).toHaveBeenCalledWith("Only owners can rename computing units");
+      expect(component.editingNameOfUnit).toBeNull();
+    });
+
+    it("enters editing mode for owners", () => {
+      const entry = makeEntry();
+      component.startEditingUnitName(entry);
+      expect(component.editingNameOfUnit).toBe(entry.computingUnit.cuid);
+      expect(component.editingUnitName).toBe(entry.computingUnit.name);
+    });
+  });
+
+  describe("cancelEditingUnitName", () => {
+    it("clears the editing state", () => {
+      component.editingNameOfUnit = 1;
+      component.editingUnitName = "half-typed";
+      component.cancelEditingUnitName();
+      expect(component.editingNameOfUnit).toBeNull();
+      expect(component.editingUnitName).toBe("");
+    });
+  });
+
+  describe("confirmUpdateUnitName", () => {
+    it("rejects an empty name without calling the rename endpoint", () => {
+      component.editingNameOfUnit = 1;
+      component.confirmUpdateUnitName(1, "   ");
+      expect(notificationService.error).toHaveBeenCalledWith("Computing unit name cannot be empty");
+      expect(computingUnitService.renameComputingUnit).not.toHaveBeenCalled();
+      expect(component.editingNameOfUnit).toBeNull();
+    });
+
+    it("renames with the trimmed name, updates the entry, refreshes, and resets editing on success", () => {
+      const refreshSpy = vi.spyOn(statusService, "refreshComputingUnitList");
+      computingUnitService.renameComputingUnit.mockReturnValue(of({} as Response));
+      component.editingNameOfUnit = 1;
+
+      component.confirmUpdateUnitName(1, "  new-name  ");
+
+      expect(computingUnitService.renameComputingUnit).toHaveBeenCalledExactlyOnceWith(1, "new-name");
+      expect(notificationService.success).toHaveBeenCalledWith("Successfully renamed computing unit");
+      expect(component.entry.computingUnit.name).toBe("new-name");
+      expect(refreshSpy).toHaveBeenCalledTimes(1);
+      expect(component.editingNameOfUnit).toBeNull();
+      expect(component.editingUnitName).toBe("");
+    });
+
+    it("does not rewrite the entry name when the cuid does not match", () => {
+      computingUnitService.renameComputingUnit.mockReturnValue(of({} as Response));
+
+      component.confirmUpdateUnitName(999, "other-name");
+
+      expect(computingUnitService.renameComputingUnit).toHaveBeenCalledExactlyOnceWith(999, "other-name");
+      expect(component.entry.computingUnit.name).toBe("unit-1");
+    });
+
+    it("notifies on failure, leaves the name unchanged, and resets editing", () => {
+      const refreshSpy = vi.spyOn(statusService, "refreshComputingUnitList");
+      computingUnitService.renameComputingUnit.mockReturnValue(throwError(() => new Error("boom")));
+      component.editingNameOfUnit = 1;
+
+      component.confirmUpdateUnitName(1, "new-name");
+
+      expect(notificationService.error).toHaveBeenCalledWith(
+        expect.stringContaining("Failed to rename computing unit:")
+      );
+      expect(component.entry.computingUnit.name).toBe("unit-1");
+      expect(refreshSpy).not.toHaveBeenCalled();
+      expect(component.editingNameOfUnit).toBeNull();
+    });
+  });
+
+  describe("onClickOpenShareAccess", () => {
+    it("delegates to the actions service with inWorkspace = false", async () => {
+      await component.onClickOpenShareAccess(42);
+      expect(actionsService.openShareAccessModal).toHaveBeenCalledExactlyOnceWith(42, false);
+    });
+  });
+
+  describe("deleted output", () => {
+    it("is an EventEmitter", () => {
+      expect(component.deleted).toBeInstanceOf(EventEmitter);
+    });
+
+    it("emits when the delete button is clicked", () => {
+      const emitted = vi.fn();
+      component.deleted.subscribe(emitted);
+      fixture.debugElement.query(By.css('button[title="Delete"]')).triggerEventHandler("click", null);
+      expect(emitted).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe("resource getters", () => {
+    it("reads resource limits straight from the unit", () => {
+      expect(component.getGpuLimit()).toBe("0");
+      expect(component.getJvmMemorySize()).toBe("0");
+      expect(component.getSharedMemorySize()).toBe("0");
+      expect(typeof component.getCpuLimit()).toBe("number");
+      expect(typeof component.getMemoryLimit()).toBe("number");
+    });
+
+    it("returns zero usage values when metrics are unavailable", () => {
+      expect(component.getCpuValue()).toBe(0);
+      expect(component.getMemoryValue()).toBe(0);
+    });
+
+    it("shows GPU selection when more than one option is available", () => {
+      expect(component.showGpuSelection()).toBe(true);
+    });
+  });
+});