diff --git a/frontend/angular.json b/frontend/angular.json index 3c07ded34e6..7ecded64fe9 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -95,8 +95,7 @@ "setupFiles": ["src/jsdom-svg-polyfill.ts"], "exclude": [ "**/app/common/service/user/config/user-config.service.spec.ts", - "**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts", - "**/app/workspace/component/workspace.component.spec.ts" + "**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts" ] } } diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts b/frontend/src/app/workspace/component/workspace.component.spec.ts index 51da6c0f2bb..0eaaf7f5fb4 100644 --- a/frontend/src/app/workspace/component/workspace.component.spec.ts +++ b/frontend/src/app/workspace/component/workspace.component.spec.ts @@ -16,3 +16,347 @@ * specific language governing permissions and limitations * under the License. */ + +import { Location } from "@angular/common"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { NzMessageService } from "ng-zorro-antd/message"; +import { EMPTY, of, Subject, throwError } from "rxjs"; + +import { NotificationService } from "../../common/service/notification/notification.service"; +import { UserService } from "../../common/service/user/user.service"; +import { WorkflowPersistService } from "../../common/service/workflow-persist/workflow-persist.service"; +import { Workflow } from "../../common/type/workflow"; +import { CodeEditorService } from "../service/code-editor/code-editor.service"; +import { WorkflowCompilingService } from "../service/compile-workflow/workflow-compiling.service"; +import { OperatorMetadataService } from "../service/operator-metadata/operator-metadata.service"; +import { UndoRedoService } from "../service/undo-redo/undo-redo.service"; +import { WorkflowConsoleService } from "../service/workflow-console/workflow-console.service"; +import { WorkflowActionService } from "../service/workflow-graph/model/workflow-action.service"; +import { OperatorReuseCacheStatusService } from "../service/workflow-status/operator-reuse-cache-status.service"; +import { EntityType, HubService } from "../../hub/service/hub.service"; +import { commonTestProviders } from "../../common/testing/test-utils"; +import { WorkspaceComponent } from "./workspace.component"; + +describe("WorkspaceComponent", () => { + let component: WorkspaceComponent; + let fixture: ComponentFixture; + + let workflowActionService: any; + let workflowPersistService: any; + let operatorMetadataService: any; + let userService: any; + let undoRedoService: any; + let notificationService: any; + let hubService: any; + let codeEditorService: any; + let messageService: any; + let routerMock: any; + let locationMock: any; + let metadataChangedSubject: Subject; + let stubGraph: { triggerCenterEvent: ReturnType; hasElementWithID: ReturnType }; + + const stubWorkflow: Workflow = { + wid: 42, + name: "test", + creationTime: 0, + lastModifiedTime: 0, + content: { + operators: [], + operatorPositions: {}, + links: [], + commentBoxes: [], + settings: { dataTransferBatchSize: 100 }, + }, + } as unknown as Workflow; + + function configureRoute(params: Record = {}, queryParams: Record = {}) { + return { + snapshot: { params, queryParams, fragment: null as string | null }, + }; + } + + async function createFixture(routeOverride: any = configureRoute()) { + metadataChangedSubject = new Subject(); + stubGraph = { + triggerCenterEvent: vi.fn(), + hasElementWithID: vi.fn().mockReturnValue(false), + }; + + workflowActionService = { + setHighlightingEnabled: vi.fn(), + resetAsNewWorkflow: vi.fn(), + disableWorkflowModification: vi.fn(), + enableWorkflowModification: vi.fn(), + reloadWorkflow: vi.fn(), + setNewSharedModel: vi.fn(), + setWorkflowMetadata: vi.fn(), + clearWorkflow: vi.fn(), + highlightElements: vi.fn(), + getTexeraGraph: vi.fn().mockReturnValue(stubGraph), + getWorkflow: vi.fn().mockReturnValue(stubWorkflow), + getWorkflowMetadata: vi.fn().mockReturnValue({ wid: 42, readonly: false }), + workflowChanged: vi.fn().mockReturnValue(EMPTY), + workflowMetaDataChanged: vi.fn().mockReturnValue(metadataChangedSubject.asObservable()), + }; + + workflowPersistService = { + isWorkflowPersistEnabled: vi.fn().mockReturnValue(true), + persistWorkflow: vi.fn().mockReturnValue(of(stubWorkflow)), + retrieveWorkflow: vi.fn().mockReturnValue(of(stubWorkflow)), + }; + + operatorMetadataService = { + getOperatorMetadata: vi.fn().mockReturnValue(of({})), + }; + + userService = { + isLogin: vi.fn().mockReturnValue(true), + getCurrentUser: vi.fn().mockReturnValue({ uid: 7 }), + }; + + undoRedoService = { + clearUndoStack: vi.fn(), + clearRedoStack: vi.fn(), + }; + + notificationService = { error: vi.fn() }; + hubService = { postView: vi.fn().mockReturnValue(of(0)) }; + codeEditorService = { vc: undefined }; + messageService = { error: vi.fn() }; + + routerMock = { navigate: vi.fn() }; + locationMock = { go: vi.fn() }; + + // TODO(#5015): drop this template override once CodeEditorComponent's + // own spec is fixed. Real child rendering would let us assert + // editor-lifecycle wiring; today we stub the host element so the + // heavyweight children don't compile in the test build. + TestBed.overrideComponent(WorkspaceComponent, { + set: { template: '
', imports: [], providers: [] }, + }); + + await TestBed.configureTestingModule({ + imports: [WorkspaceComponent, HttpClientTestingModule], + providers: [ + { provide: WorkflowActionService, useValue: workflowActionService }, + { provide: WorkflowPersistService, useValue: workflowPersistService }, + { provide: OperatorMetadataService, useValue: operatorMetadataService }, + { provide: UserService, useValue: userService }, + { provide: UndoRedoService, useValue: undoRedoService }, + { provide: NotificationService, useValue: notificationService }, + { provide: HubService, useValue: hubService }, + { provide: CodeEditorService, useValue: codeEditorService }, + { provide: NzMessageService, useValue: messageService }, + { provide: Router, useValue: routerMock }, + { provide: Location, useValue: locationMock }, + { provide: ActivatedRoute, useValue: routeOverride }, + // The three services listed in the constructor only to force their + // initialization aren't exercised by any test here; provide stubs. + { provide: WorkflowCompilingService, useValue: {} }, + { provide: WorkflowConsoleService, useValue: {} }, + { provide: OperatorReuseCacheStatusService, useValue: {} }, + ...commonTestProviders, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(WorkspaceComponent); + component = fixture.componentInstance; + // ngOnDestroy clears the ViewContainerRef bound to `#codeEditor`. Tests that + // exercise individual methods skip change detection, so the @ViewChild query + // is never resolved; assign a stub to keep TestBed teardown from throwing. + component.codeEditorViewRef = { clear: vi.fn() } as any; + } + + describe("ngOnInit", () => { + it("parses numeric pid from route query params", async () => { + await createFixture(configureRoute({}, { pid: "13" })); + component.ngOnInit(); + expect(component.pid).toBe(13); + }); + + it("treats non-numeric pid as undefined", async () => { + await createFixture(configureRoute({}, { pid: "not-a-number" })); + component.ngOnInit(); + expect(component.pid).toBeUndefined(); + }); + + it("enables highlighting on the workflow action service", async () => { + await createFixture(); + component.ngOnInit(); + expect(workflowActionService.setHighlightingEnabled).toHaveBeenCalledWith(true); + }); + }); + + describe("ngAfterViewInit", () => { + it("cold start (no wid in route): does not flip isLoading and registers metadata listener", async () => { + await createFixture(configureRoute({})); + fixture.detectChanges(); // triggers ngOnInit + ngAfterViewInit + expect(component.isLoading).toBe(false); + expect(workflowActionService.disableWorkflowModification).not.toHaveBeenCalled(); + expect(operatorMetadataService.getOperatorMetadata).toHaveBeenCalled(); + }); + + it("warm start (wid in route): sets isLoading=true and disables modification before load", async () => { + await createFixture(configureRoute({ id: "42" })); + // retrieveWorkflow is consumed inside loadWorkflowWithId — keep it pending so + // we can observe the pre-completion loading state. + workflowPersistService.retrieveWorkflow.mockReturnValue(new Subject()); + fixture.detectChanges(); + expect(component.isLoading).toBe(true); + expect(workflowActionService.disableWorkflowModification).toHaveBeenCalled(); + }); + }); + + describe("loadWorkflowWithId", () => { + it("on success: hands the workflow to the action service, clears undo/redo, and turns off loading", async () => { + await createFixture(configureRoute({ id: "42" })); + fixture.detectChanges(); + expect(workflowActionService.setNewSharedModel).toHaveBeenCalledWith(42, { uid: 7 }); + expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith(stubWorkflow); + expect(undoRedoService.clearUndoStack).toHaveBeenCalled(); + expect(undoRedoService.clearRedoStack).toHaveBeenCalled(); + expect(component.isLoading).toBe(false); + }); + + it("on failure: resets to a new workflow, surfaces an access error, and turns off loading", async () => { + await createFixture(configureRoute({ id: "42" })); + workflowPersistService.retrieveWorkflow.mockReturnValue(throwError(() => new Error("403"))); + fixture.detectChanges(); + expect(workflowActionService.resetAsNewWorkflow).toHaveBeenCalled(); + expect(workflowActionService.enableWorkflowModification).toHaveBeenCalled(); + expect(messageService.error).toHaveBeenCalledWith(expect.stringContaining("don't have access")); + expect(component.isLoading).toBe(false); + }); + + it("flags broken workflows via NotificationService.error but still loads them", async () => { + const brokenWorkflow = { + ...stubWorkflow, + content: { + ...stubWorkflow.content, + // link references operator IDs that aren't in `operators: []` → broken. + links: [{ source: { operatorID: "ghost-a" }, target: { operatorID: "ghost-b" } }], + }, + } as unknown as Workflow; + await createFixture(configureRoute({ id: "42" })); + workflowPersistService.retrieveWorkflow.mockReturnValue(of(brokenWorkflow)); + fixture.detectChanges(); + expect(notificationService.error).toHaveBeenCalledWith(expect.stringContaining("broken")); + // Workflow still flows through reload — the error is informational, not blocking. + expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith(brokenWorkflow); + }); + + it("when URL fragment matches an element in the graph, highlights it", async () => { + const route = configureRoute({ id: "42" }); + route.snapshot.fragment = "operator-1"; + await createFixture(route); + stubGraph.hasElementWithID.mockReturnValue(true); + fixture.detectChanges(); + expect(stubGraph.hasElementWithID).toHaveBeenCalledWith("operator-1"); + expect(workflowActionService.highlightElements).toHaveBeenCalledWith(false, "operator-1"); + }); + + it("when URL fragment does not match any element, surfaces an error and clears the fragment", async () => { + const route = configureRoute({ id: "42" }); + route.snapshot.fragment = "stale-id"; + await createFixture(route); + // Default mock already returns false, but state explicitly for clarity. + stubGraph.hasElementWithID.mockReturnValue(false); + fixture.detectChanges(); + expect(notificationService.error).toHaveBeenCalledWith(expect.stringContaining("stale-id")); + // Two router.navigate calls: one preserving fragment, one clearing it. + expect(routerMock.navigate).toHaveBeenLastCalledWith([], { relativeTo: route }); + }); + }); + + describe("triggerCenter", () => { + it("delegates to the texera graph", async () => { + await createFixture(); + component.triggerCenter(); + expect(stubGraph.triggerCenterEvent).toHaveBeenCalledTimes(1); + }); + }); + + describe("registerAutoPersistWorkflow", () => { + it("is idempotent — only subscribes to workflowChanged once across repeated calls", async () => { + await createFixture(); + component.registerAutoPersistWorkflow(); + component.registerAutoPersistWorkflow(); + component.registerAutoPersistWorkflow(); + expect(workflowActionService.workflowChanged).toHaveBeenCalledTimes(1); + }); + }); + + describe("updateViewCount", () => { + it("posts a view event with the route's wid and the current user's uid", async () => { + const route = configureRoute({ id: "42" }); + await createFixture(route); + fixture.detectChanges(); + expect(hubService.postView).toHaveBeenCalledWith("42", 7, EntityType.Workflow); + }); + + it("falls back to uid=0 when no user is signed in", async () => { + const route = configureRoute({ id: "42" }); + await createFixture(route); + userService.getCurrentUser.mockReturnValue(undefined); + // Re-trigger after mutating the mock; createFixture has already wired it. + component.updateViewCount(); + expect(hubService.postView).toHaveBeenCalledWith("42", 0, EntityType.Workflow); + }); + }); + + describe("onWIDChange", () => { + it("syncs writeAccess from metadata.readonly each time the metadata changes", async () => { + await createFixture(); + fixture.detectChanges(); + expect(component.writeAccess).toBe(false); // default before any emission + + workflowActionService.getWorkflowMetadata.mockReturnValue({ wid: 42, readonly: false }); + metadataChangedSubject.next(); + expect(component.writeAccess).toBe(true); + + workflowActionService.getWorkflowMetadata.mockReturnValue({ wid: 42, readonly: true }); + metadataChangedSubject.next(); + expect(component.writeAccess).toBe(false); + }); + + it("ignores metadata emissions that have no wid yet", async () => { + await createFixture(); + fixture.detectChanges(); + workflowActionService.getWorkflowMetadata.mockReturnValue({ wid: undefined, readonly: false }); + metadataChangedSubject.next(); + // writeAccess stays at its initial false — no metadata.wid means we don't know + // whether the workflow is editable yet. + expect(component.writeAccess).toBe(false); + }); + }); + + describe("ngOnDestroy", () => { + it("persists the workflow on destroy when the user is signed in and persist is enabled", async () => { + await createFixture(); + component.ngOnDestroy(); + expect(workflowPersistService.persistWorkflow).toHaveBeenCalledWith(stubWorkflow); + expect(workflowActionService.clearWorkflow).toHaveBeenCalled(); + }); + + it("skips the persist call when the user is not signed in", async () => { + await createFixture(); + userService.isLogin.mockReturnValue(false); + component.ngOnDestroy(); + expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled(); + // Cleanup of the workflow state still happens regardless. + expect(workflowActionService.clearWorkflow).toHaveBeenCalled(); + }); + }); + + describe("copilotEnabled", () => { + it("passes through to GuiConfigService.env.copilotEnabled", async () => { + await createFixture(); + // MockGuiConfigService defaults `copilotEnabled` to false. + expect(component.copilotEnabled).toBe(false); + }); + }); +}); diff --git a/frontend/src/tsconfig.spec.json b/frontend/src/tsconfig.spec.json index 5e9a1f049c5..ec4745fc088 100644 --- a/frontend/src/tsconfig.spec.json +++ b/frontend/src/tsconfig.spec.json @@ -14,7 +14,6 @@ // Specs whose body is entirely commented out / placeholder — these // need real test cases written before they can be re-enabled. "**/app/common/service/user/config/user-config.service.spec.ts", - "**/app/workspace/component/workspace.component.spec.ts", // jointjs paper geometry: every test in this suite asserts on // graph layout math (positions, link routing, hit testing) that