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-6604-3a671c31ffe7f5a48da5b2e98841b0d55e176acc in repository https://gitbox.apache.org/repos/asf/texera.git
commit b6cf569c312ff0013bc47e3401be85e1bee05f7f Author: Meng Wang <[email protected]> AuthorDate: Mon Jul 20 12:39:25 2026 -0700 test(frontend): extend UserWorkflowListItemComponent and BrowseSectionComponent coverage (#6604) ### What changes were proposed in this PR? Extends the existing specs for two components (both asserted little past creation) with unit tests for their previously-untested methods, per `frontend/TESTING.md`. **`UserWorkflowListItemComponent`** (codecov ~62%) — 11 new tests: - `getProjectIds` returns the entry's project-id set; `isLightColor` reports light vs dark hex colors. - `confirmUpdateWorkflowCustomName` / `confirmUpdateWorkflowCustomDescription` persist via `WorkflowPersistService`, update the workflow, and stop editing; the name falls back to the default when empty; both no-op when the workflow has no id. - `removeWorkflowFromProject` calls `UserProjectService` and prunes the entry's project ids; `onClickGetWorkflowExecutions` opens the execution-history modal; `onClickDownloadWorkfllow` delegates to `DownloadService` (and no-ops without an id). **`BrowseSectionComponent`** (codecov ~72%) — 4 new tests: - `initializeEntry` skips entries whose id is not a number and throws on an unexpected entity type. - `loadCoverImages` / `getCoverImage` build and cache `${apiEndpoint}/dataset/{id}/cover` for a dataset with a cover image, and fall back to the default background otherwise. No production code was changed. ### Any related issues, documentation, discussions? Closes #6590 ### How was this PR tested? Extended Vitest specs, run locally in `frontend/` (each whole file green, run 4× to confirm determinism; failure paths verified by breaking an assertion in each): ``` ng test --watch=false --include .../user-workflow-list-item.component.spec.ts # 15 passed ng test --watch=false --include .../browse-section.component.spec.ts # 9 passed eslint <specs> # clean prettier --check # clean ``` Services are stubbed (`vi.spyOn` / `useValue`), so the specs make no network calls. The list-item tests that mutate a workflow (rename, remove-from-project) use a freshly-constructed `DashboardEntry` per test so mutations cannot leak into the shared `testWorkflowEntries` fixture. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../user-workflow-list-item.component.spec.ts | 154 ++++++++++++++++++++- .../browse-section.component.spec.ts | 38 +++++ 2 files changed, 189 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts index 494c079308..4174dc05d4 100644 --- a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts @@ -21,14 +21,21 @@ import { Component, ViewChild } from "@angular/core"; import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; import { UserWorkflowListItemComponent } from "./user-workflow-list-item.component"; import { FileSaverService } from "../../../../service/user/file/file-saver.service"; -import { testWorkflowEntries } from "../../../user-dashboard-test-fixtures"; +import { testWorkflow1, testWorkflowEntries } from "../../../user-dashboard-test-fixtures"; import { By } from "@angular/platform-browser"; import { StubWorkflowPersistService } from "../../../../../common/service/workflow-persist/stub-workflow-persist.service"; -import { WorkflowPersistService } from "../../../../../common/service/workflow-persist/workflow-persist.service"; +import { + DEFAULT_WORKFLOW_NAME, + WorkflowPersistService, +} from "../../../../../common/service/workflow-persist/workflow-persist.service"; import { UserProjectService } from "../../../../service/user/project/user-project.service"; import { StubUserProjectService } from "../../../../service/user/project/stub-user-project.service"; +import { DownloadService } from "../../../../service/user/download/download.service"; +import { WorkflowExecutionHistoryComponent } from "../ngbd-modal-workflow-executions/workflow-execution-history.component"; +import { Workflow } from "../../../../../common/type/workflow"; +import { of } from "rxjs"; import { NzListComponent } from "ng-zorro-antd/list"; -import { NzModalModule } from "ng-zorro-antd/modal"; +import { NzModalModule, NzModalService } from "ng-zorro-antd/modal"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { provideRouter } from "@angular/router"; import { DashboardEntry } from "../../../../type/dashboard-entry"; @@ -55,6 +62,20 @@ class TestHostComponent { @ViewChild(UserWorkflowListItemComponent, { static: true }) inner!: UserWorkflowListItemComponent; } +// A fresh DashboardEntry per call so methods that mutate the workflow (rename, +// remove-from-project) cannot leak into the shared testWorkflowEntries fixture. +function makeWorkflowEntry(workflowOverrides: Partial<Workflow> = {}, projectIDs: number[] = [1]): DashboardEntry { + return new DashboardEntry({ + workflow: { ...testWorkflow1, ...workflowOverrides }, + isOwner: true, + ownerName: "Texera", + accessLevel: "Write", + projectIDs: [...projectIDs], + ownerId: 1, + coverImage: null, + }); +} + describe("UserWorkflowListItemComponent", () => { let component: UserWorkflowListItemComponent; let fixture: ComponentFixture<TestHostComponent>; @@ -133,6 +154,133 @@ describe("UserWorkflowListItemComponent", () => { }); })); + describe("method coverage", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("getProjectIds returns the set of the entry's project ids", () => { + component.entry = makeWorkflowEntry({}, [1, 2, 3]); + expect(component.getProjectIds()).toEqual(new Set([1, 2, 3])); + }); + + it("isLightColor reports light vs dark hex colors", () => { + expect(component.isLightColor("ffffff")).toBe(true); + expect(component.isLightColor("000000")).toBe(false); + }); + + describe("confirmUpdateWorkflowCustomName", () => { + it("persists the new name, updates the workflow, and stops editing", () => { + const persist = TestBed.inject(WorkflowPersistService); + // The stub lacks updateWorkflowName; give this fresh-per-test instance a spy. + const spy = ((persist as any).updateWorkflowName = vi.fn().mockReturnValue(of(undefined))); + component.entry = makeWorkflowEntry({ wid: 5, name: "old" }); + component.editingName = true; + + component.confirmUpdateWorkflowCustomName("new name"); + + expect(spy).toHaveBeenCalledWith(5, "new name"); + expect(component.workflow.name).toBe("new name"); + expect(component.editingName).toBe(false); + }); + + it("falls back to the default name when the input is empty", () => { + const persist = TestBed.inject(WorkflowPersistService); + const spy = ((persist as any).updateWorkflowName = vi.fn().mockReturnValue(of(undefined))); + component.entry = makeWorkflowEntry({ wid: 5 }); + + component.confirmUpdateWorkflowCustomName(""); + + expect(spy).toHaveBeenCalledWith(5, DEFAULT_WORKFLOW_NAME); + expect(component.workflow.name).toBe(DEFAULT_WORKFLOW_NAME); + }); + + it("is a no-op when the workflow has no id", () => { + const persist = TestBed.inject(WorkflowPersistService); + const spy = ((persist as any).updateWorkflowName = vi.fn()); + component.entry = makeWorkflowEntry({ wid: undefined }); + + component.confirmUpdateWorkflowCustomName("x"); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe("confirmUpdateWorkflowCustomDescription", () => { + it("persists the new description and stops editing", () => { + const persist = TestBed.inject(WorkflowPersistService); + const spy = ((persist as any).updateWorkflowDescription = vi.fn().mockReturnValue(of(undefined))); + component.entry = makeWorkflowEntry({ wid: 5 }); + component.editingDescription = true; + + component.confirmUpdateWorkflowCustomDescription("new desc"); + + expect(spy).toHaveBeenCalledWith(5, "new desc"); + expect(component.workflow.description).toBe("new desc"); + expect(component.editingDescription).toBe(false); + }); + + it("is a no-op when the workflow has no id", () => { + const persist = TestBed.inject(WorkflowPersistService); + const spy = ((persist as any).updateWorkflowDescription = vi.fn()); + component.entry = makeWorkflowEntry({ wid: undefined }); + + component.confirmUpdateWorkflowCustomDescription("x"); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + it("removeWorkflowFromProject calls the service and prunes the entry's project ids", () => { + const userProject = TestBed.inject(UserProjectService); + const spy = vi.spyOn(userProject, "removeWorkflowFromProject").mockReturnValue(of({} as Response)); + component.entry = makeWorkflowEntry({ wid: 9 }, [1, 2]); + + component.removeWorkflowFromProject(1); + + expect(spy).toHaveBeenCalledWith(1, 9); + expect([...component.entry.workflow.projectIDs]).toEqual([2]); + }); + + it("onClickGetWorkflowExecutions opens the execution-history modal for the workflow", () => { + const modal = TestBed.inject(NzModalService); + const spy = vi.spyOn(modal, "create").mockReturnValue({} as any); + component.entry = makeWorkflowEntry({ wid: 9, name: "wf" }); + + component.onClickGetWorkflowExecutions(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + nzContent: WorkflowExecutionHistoryComponent, + nzData: { wid: 9 }, + nzTitle: "Execution results of Workflow: wf", + }) + ); + }); + + describe("onClickDownloadWorkfllow", () => { + it("delegates to the download service with the workflow id and name", () => { + const download = TestBed.inject(DownloadService); + const spy = vi.spyOn(download, "downloadWorkflow").mockReturnValue(of(undefined) as any); + component.entry = makeWorkflowEntry({ wid: 9, name: "wf" }); + + component.onClickDownloadWorkfllow(); + + expect(spy).toHaveBeenCalledWith(9, "wf"); + }); + + it("does nothing when the workflow has no id", () => { + const download = TestBed.inject(DownloadService); + const spy = vi.spyOn(download, "downloadWorkflow"); + component.entry = makeWorkflowEntry({ wid: undefined }); + + component.onClickDownloadWorkfllow(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + }); + function sendInput(editableDescriptionInput: HTMLInputElement, text: string) { // Helper function to change the workflow description textbox. editableDescriptionInput.value = text; diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts index 504101e32e..8207eeab5f 100644 --- a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts +++ b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts @@ -24,6 +24,7 @@ import { DatasetService } from "../../../dashboard/service/user/dataset/dataset. import { ChangeDetectorRef } from "@angular/core"; import { commonTestProviders } from "../../../common/testing/test-utils"; import { DashboardEntry } from "../../../dashboard/type/dashboard-entry"; +import { AppSettings } from "../../../common/app-setting"; import { HUB_DATASET_RESULT_DETAIL, HUB_WORKFLOW_RESULT_DETAIL, @@ -83,4 +84,41 @@ describe("BrowseSectionComponent", () => { expect(component.entityRoutes[201]).toEqual([HUB_DATASET_RESULT_DETAIL, "201"]); }); }); + + describe("initializeEntry edge cases", () => { + it("skips entries whose id is not a number", () => { + component.entities = [{ id: undefined, type: "dataset", accessibleUserIds: [] } as unknown as DashboardEntry]; + component.ngOnInit(); + expect(Object.keys(component.entityRoutes)).toHaveLength(0); + }); + + it("throws on an unexpected entity type", () => { + const bad = { id: 7, type: "project", accessibleUserIds: [] } as unknown as DashboardEntry; + expect(() => (component as any).initializeEntry(bad)).toThrowError("Unexpected type in DashboardEntry."); + }); + }); + + describe("cover images", () => { + it("builds and caches the cover URL for a dataset that has a cover image", () => { + const entity = { + id: 5, + type: "dataset", + coverImageUrl: "has-cover", + accessibleUserIds: [], + } as unknown as DashboardEntry; + component.entities = [entity]; + component.ngOnInit(); + + expect(component.getCoverImage(entity)).toBe(`${AppSettings.getApiEndpoint()}/dataset/5/cover`); + }); + + it("falls back to the default background when no cover was cached", () => { + // No coverImageUrl -> loadCoverImages skips it -> getCoverImage returns the default. + const entity = { id: 6, type: "dataset", accessibleUserIds: [] } as unknown as DashboardEntry; + component.entities = [entity]; + component.ngOnInit(); + + expect(component.getCoverImage(entity)).toBe(component.defaultBackground); + }); + }); });
