Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@
"**/app/workspace/component/menu/menu.component.spec.ts",
"**/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts",
"**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts",
"**/app/workspace/component/workspace.component.spec.ts",
"**/app/workspace/service/preset/preset.service.spec.ts"
]
}
Expand Down
340 changes: 340 additions & 0 deletions frontend/src/app/workspace/component/workspace.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,343 @@
* 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() };

TestBed.overrideComponent(WorkspaceComponent, {
set: { template: '<div #codeEditor class="stub-host"></div>', imports: [], providers: [] },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't load children, the test cannot verify the side effects after loading them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code editor has other issue on tests. do you think we can enable it later? or maybe we can fix code editor's test first. WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can fix it later. Let's note it somewhere.

});

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);
});
});
});
1 change: 0 additions & 1 deletion frontend/src/tsconfig.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"**/app/common/service/user/config/user-config.service.spec.ts",
"**/app/workspace/component/left-panel/settings/settings.component.spec.ts",
"**/app/workspace/component/menu/menu.component.spec.ts",
"**/app/workspace/component/workspace.component.spec.ts",
"**/app/workspace/service/preset/preset.service.spec.ts",

// Specs that compile but fail at runtime against jsdom's gaps:
Expand Down
Loading