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

Yicong-Huang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new bd445b5157 test(frontend): add unit tests for WorkspaceComponent 
(#4969)
bd445b5157 is described below

commit bd445b5157253762209deb8cb4509e4343297e12
Author: Yicong Huang <[email protected]>
AuthorDate: Sun May 10 16:30:11 2026 -0700

    test(frontend): add unit tests for WorkspaceComponent (#4969)
    
    ### What changes were proposed in this PR?
    
    The placeholder spec at
    `frontend/src/app/workspace/component/workspace.component.spec.ts` was a
    license header only. Replace it with real tests covering the public
    surface of `WorkspaceComponent`:
    
    - `ngOnInit` — query-param `pid` parsing (numeric / non-numeric) and
    `setHighlightingEnabled` wiring
    - `ngAfterViewInit` — cold-start (no `wid`) vs warm-start (`wid`
    present, must show loading and disable modification); also asserts
    `HubService.postView` is called with the route's `wid` and the current
    user's `uid`, falling back to `0` when anonymous
    - `loadWorkflowWithId` — success / 403-error paths, the broken-workflow
    branch (`checkIfWorkflowBroken` → `NotificationService.error` but still
    loads), and URL-fragment handling (highlight when present, error + clear
    when stale)
    - `onWIDChange` — `writeAccess` syncs from `metadata.readonly` on each
    `workflowMetaDataChanged` emission, and stays put when the metadata's
    `wid` is still undefined
    - `triggerCenter` — delegates to
    `WorkflowActionService.getTexeraGraph().triggerCenterEvent()`
    - `registerAutoPersistWorkflow` — idempotent (only one subscription
    regardless of repeated calls)
    - `ngOnDestroy` — persists the workflow when the user is signed in and
    persist is enabled; skips persist when not signed in; clears the
    workflow either way
    - `copilotEnabled` — passes through
    `GuiConfigService.env.copilotEnabled`
    
    The fixture overrides the component template to `<div #codeEditor>` so
    the heavyweight children (workflow editor, mini-map, left/right panels)
    don't have to compile in the test build, and stubs all 14 injected
    services. Drop the spec from the exclude lists in `tsconfig.spec.json`
    and `angular.json`.
    
    ### Any related issues, documentation, discussions?
    
    Closes #4967.
    
    ### How was this PR tested?
    
    `yarn ng test --watch=false`: 267 pass, 8 skip, 2 todo (19 new tests in
    `WorkspaceComponent`). `yarn format:ci` clean.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.7 (1M context)
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
 frontend/angular.json                              |   3 +-
 .../component/workspace.component.spec.ts          | 344 +++++++++++++++++++++
 frontend/src/tsconfig.spec.json                    |   1 -
 3 files changed, 345 insertions(+), 3 deletions(-)

diff --git a/frontend/angular.json b/frontend/angular.json
index 3c07ded34e..7ecded64fe 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 51da6c0f2b..0eaaf7f5fb 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<WorkspaceComponent>;
+
+  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<void>;
+  let stubGraph: { triggerCenterEvent: ReturnType<typeof vi.fn>; 
hasElementWithID: ReturnType<typeof vi.fn> };
+
+  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<string, any> = {}, queryParams: 
Record<string, any> = {}) {
+    return {
+      snapshot: { params, queryParams, fragment: null as string | null },
+    };
+  }
+
+  async function createFixture(routeOverride: any = configureRoute()) {
+    metadataChangedSubject = new Subject<void>();
+    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: '<div #codeEditor class="stub-host"></div>', 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 5e9a1f049c..ec4745fc08 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

Reply via email to