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/12 02:07:01 UTC
(texera) 01/04: test(frontend): extend WorkflowActionService coverage (#6352)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 95b9935ccee88c2be06c5a6484c566fd6d3d93ac
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Sat Jul 11 18:41:53 2026 -0700
test(frontend): extend WorkflowActionService coverage (#6352)
### What changes were proposed in this PR?
Extends the `WorkflowActionService` spec (was ~52% covered):
`reloadWorkflow`/`clearWorkflow`/`resetAsNewWorkflow`,
metadata/name/settings/batch-size/execution-mode/published setters,
temp-workflow yjs ws-provider connect/disconnect, add/remove port,
disable/reuse/view-result sets, highlight ports/elements,
`setOperatorVersion`/`setPortProperty`, and auto-layout positioning.
### Any related issues, documentation, discussions?
Closes #6349.
### How was this PR tested?
Added tests to the existing spec (now 37), reusing its real
TexeraGraph/joint-graph harness with undo-redo and ws-provider spies.
Verified locally with Vitest (`ng test --watch=false --include=...`) and
CI's `format:ci` gate (prettier-eslint + eslint) clean. Test-only change
— no production code is modified.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
---------
Co-authored-by: probe <pr...@x>
---
.../model/workflow-action.service.spec.ts | 315 ++++++++++++++++++++-
1 file changed, 313 insertions(+), 2 deletions(-)
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
index 4ddf3b9006..d69995dce7 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
@@ -26,6 +26,7 @@ import {
mockCommentBox,
mockFalseResultSentimentLink,
mockFalseSentimentScanLink,
+ mockMultiInputOutputPredicate,
mockPoint,
mockResultPredicate,
mockScanPredicate,
@@ -36,10 +37,11 @@ import {
} from "./mock-workflow-data";
import { inject, TestBed } from "@angular/core/testing";
-import { WorkflowActionService } from "./workflow-action.service";
-import { OperatorPredicate } from "../../../types/workflow-common.interface";
+import { DEFAULT_WORKFLOW, DEFAULT_WORKFLOW_NAME, WorkflowActionService } from "./workflow-action.service";
+import { LogicalPort, OperatorPredicate } from "../../../types/workflow-common.interface";
import { WorkflowUtilService } from "../util/workflow-util.service";
import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { ExecutionMode, Workflow, WorkflowSettings } from "../../../../common/type/workflow";
describe("WorkflowActionService", () => {
let service: WorkflowActionService;
@@ -277,4 +279,313 @@ describe("WorkflowActionService", () => {
// expect(sentimentOpPos).toEqual(mockPoint);
// expect(resultOpPos).toEqual(mockPoint);
});
+
+ it("should reload a workflow, repopulating the graph and clearing the undo/redo stacks", () => {
+ const settings: WorkflowSettings = {
+ dataTransferBatchSize: 250,
+ executionMode: ExecutionMode.MATERIALIZED,
+ };
+ const workflow: Workflow = {
+ ...DEFAULT_WORKFLOW,
+ name: "Reloaded WF",
+ content: {
+ operators: [mockScanPredicate, mockResultPredicate],
+ operatorPositions: {
+ [mockScanPredicate.operatorID]: mockPoint,
+ [mockResultPredicate.operatorID]: mockPoint,
+ },
+ links: [mockScanResultLink],
+ commentBoxes: [{ ...mockCommentBox, commentBoxID: "commentBox-1" }],
+ settings,
+ },
+ };
+ const clearUndoSpy = vi.spyOn(undoRedo, "clearUndoStack");
+ const clearRedoSpy = vi.spyOn(undoRedo, "clearRedoStack");
+ const restoreSpy = vi.spyOn(service.getJointGraphWrapper(), "restoreDefaultZoomAndOffset");
+
+ service.reloadWorkflow(workflow);
+
+ expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBeTruthy();
+ expect(texeraGraph.hasOperator(mockResultPredicate.operatorID)).toBeTruthy();
+ expect(texeraGraph.hasLinkWithID(mockScanResultLink.linkID)).toBeTruthy();
+ expect(texeraGraph.hasCommentBox("commentBox-1")).toBeTruthy();
+ expect(service.getWorkflowMetadata().name).toEqual("Reloaded WF");
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(250);
+ expect(clearUndoSpy).toHaveBeenCalled();
+ expect(clearRedoSpy).toHaveBeenCalled();
+ expect(restoreSpy).toHaveBeenCalled();
+ });
+
+ it("should throw when a reloaded operator is missing its position", () => {
+ const workflow: Workflow = {
+ ...DEFAULT_WORKFLOW,
+ content: {
+ operators: [mockScanPredicate],
+ operatorPositions: {},
+ links: [],
+ commentBoxes: [],
+ settings: { dataTransferBatchSize: 100, executionMode: ExecutionMode.PIPELINED },
+ },
+ };
+ expect(() => service.reloadWorkflow(workflow)).toThrowError(
+ new RegExp(`position error: ${mockScanPredicate.operatorID}`)
+ );
+ });
+
+ it("should empty the graph and skip viewport restore when reloading undefined", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ const restoreSpy = vi.spyOn(service.getJointGraphWrapper(), "restoreDefaultZoomAndOffset");
+
+ service.reloadWorkflow(undefined, false, false);
+
+ expect(texeraGraph.getAllOperators().length).toEqual(0);
+ expect(restoreSpy).not.toHaveBeenCalled();
+ });
+
+ it("should clear the workflow back to its defaults", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ service.setWorkflowName("Something");
+ service.setWorkflowDataTransferBatchSize(999);
+ service.setHighlightingEnabled(true);
+
+ service.clearWorkflow();
+
+ expect(service.getWorkflowMetadata()).toEqual(DEFAULT_WORKFLOW);
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(100);
+ expect(service.getWorkflowSettings().executionMode).toEqual(ExecutionMode.PIPELINED);
+ expect(texeraGraph.getAllOperators().length).toEqual(0);
+ expect(service.getHighlightingEnabled()).toBeFalsy();
+ });
+
+ it("should emit on workflowChanged when resetting as a new workflow", () => {
+ let changed = false;
+ service.workflowChanged().subscribe(() => (changed = true));
+
+ service.resetAsNewWorkflow();
+
+ expect(changed).toBeTruthy();
+ });
+
+ it("should only emit metadata changes when the reference changes", () => {
+ const current = service.getWorkflowMetadata();
+ let emitCount = 0;
+ service.workflowMetaDataChanged().subscribe(() => (emitCount += 1));
+
+ // identical reference -> no emission
+ service.setWorkflowMetadata(current);
+ expect(emitCount).toEqual(0);
+
+ // new metadata -> emission
+ service.setWorkflowMetadata({ ...DEFAULT_WORKFLOW, name: "renamed" });
+ expect(emitCount).toEqual(1);
+ expect(service.getWorkflowMetadata().name).toEqual("renamed");
+ });
+
+ it("should set the workflow name, defaulting blank names", () => {
+ service.setWorkflowName(" ");
+ expect(service.getWorkflowMetadata().name).toEqual(DEFAULT_WORKFLOW_NAME);
+
+ service.setWorkflowName("My Workflow");
+ expect(service.getWorkflowMetadata().name).toEqual("My Workflow");
+ });
+
+ it("should manage workflow settings and publish state", () => {
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(100);
+ expect(service.getWorkflowSettings().executionMode).toEqual(ExecutionMode.PIPELINED);
+
+ // non-positive batch sizes are ignored, positive ones are applied
+ service.setWorkflowDataTransferBatchSize(0);
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(100);
+ service.setWorkflowDataTransferBatchSize(400);
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(400);
+
+ service.updateExecutionMode(ExecutionMode.MATERIALIZED);
+ expect(service.getWorkflowSettings().executionMode).toEqual(ExecutionMode.MATERIALIZED);
+
+ service.setWorkflowIsPublished(1);
+ expect(service.getWorkflowMetadata().isPublished).toEqual(1);
+
+ const settings: WorkflowSettings = { dataTransferBatchSize: 42, executionMode: ExecutionMode.PIPELINED };
+ service.setWorkflowSettings(settings);
+ expect(service.getWorkflowSettings()).toEqual(settings);
+ });
+
+ it("should toggle the shared-editing connection through temp workflow", () => {
+ const wsProvider = texeraGraph.sharedModel.wsProvider;
+ // setTempWorkflow only disconnects when the provider is in the "should connect" state,
+ // so force it on to make the disconnect assertion deterministic.
+ (wsProvider as any).shouldConnect = true;
+ const disconnectSpy = vi.spyOn(wsProvider, "disconnect").mockImplementation(() => {});
+ const connectSpy = vi.spyOn(wsProvider, "connect").mockImplementation(() => {});
+ const tempWorkflow: Workflow = {
+ ...DEFAULT_WORKFLOW,
+ content: {
+ operators: [],
+ operatorPositions: {},
+ links: [],
+ commentBoxes: [],
+ settings: { dataTransferBatchSize: 100, executionMode: ExecutionMode.PIPELINED },
+ },
+ };
+
+ service.setTempWorkflow(tempWorkflow);
+ expect(disconnectSpy).toHaveBeenCalled();
+ expect(service.getTempWorkflow()).toBe(tempWorkflow);
+
+ service.resetTempWorkflow();
+ expect(connectSpy).toHaveBeenCalled();
+ expect(service.getTempWorkflow()).toBeUndefined();
+ });
+
+ it("should add a dynamic input port to both the texera and joint graphs", () => {
+ const dynamicOp: OperatorPredicate = {
+ ...mockMultiInputOutputPredicate,
+ dynamicInputPorts: true,
+ dynamicOutputPorts: true,
+ };
+ service.addOperator(dynamicOp, mockPoint);
+ const element = jointGraph.getCell(dynamicOp.operatorID) as joint.dia.Element;
+ const beforeInputs = texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length;
+ const beforePorts = element.getPorts().length;
+
+ service.addPort(dynamicOp.operatorID, true);
+
+ expect(texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length).toEqual(beforeInputs + 1);
+ expect(element.getPorts().length).toEqual(beforePorts + 1);
+ });
+
+ it("should honor disallowMultiInputs when adding an input port", () => {
+ const dynamicOp: OperatorPredicate = { ...mockMultiInputOutputPredicate, dynamicInputPorts: true };
+ service.addOperator(dynamicOp, mockPoint);
+
+ service.addPort(dynamicOp.operatorID, true, true);
+
+ const inputPorts = texeraGraph.getOperator(dynamicOp.operatorID).inputPorts;
+ expect(inputPorts[inputPorts.length - 1].disallowMultiInputs).toBe(true);
+ });
+
+ it("should remove the last dynamic port from both the texera and joint graphs", () => {
+ const dynamicOp: OperatorPredicate = { ...mockMultiInputOutputPredicate, dynamicInputPorts: true };
+ service.addOperator(dynamicOp, mockPoint);
+ const element = jointGraph.getCell(dynamicOp.operatorID) as joint.dia.Element;
+ // Add a dynamic input port first, then remove it — so this exercises the dynamic-port
+ // removal path (round-tripping back to the original counts) rather than just deleting a
+ // pre-existing port.
+ const baseInputs = texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length;
+ const basePorts = element.getPorts().length;
+ service.addPort(dynamicOp.operatorID, true);
+ expect(texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length).toEqual(baseInputs + 1);
+
+ service.removePort(dynamicOp.operatorID, true);
+
+ expect(texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length).toEqual(baseInputs);
+ expect(element.getPorts().length).toEqual(basePorts);
+ });
+
+ it("should throw when adding a port to an operator without dynamic ports", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ expect(() => service.addPort(mockScanPredicate.operatorID, true)).toThrowError(
+ new RegExp("does not have dynamic input ports")
+ );
+ });
+
+ it("should disable and enable operators, reflecting in the graph and change stream", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ let event: { newDisabled: readonly string[]; newEnabled: readonly string[] } | undefined;
+ texeraGraph.getDisabledOperatorsChangedStream().subscribe(e => (event = e));
+
+ service.disableOperators([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getDisabledOperators().has(mockScanPredicate.operatorID)).toBeTruthy();
+ expect(event?.newDisabled).toContain(mockScanPredicate.operatorID);
+
+ service.enableOperators([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getDisabledOperators().has(mockScanPredicate.operatorID)).toBeFalsy();
+ expect(event?.newEnabled).toContain(mockScanPredicate.operatorID);
+ });
+
+ it("should mark and unmark operators for result reuse", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ let event: { newReuseCacheOps: readonly string[]; newUnreuseCacheOps: readonly string[] } | undefined;
+ texeraGraph.getReuseCacheOperatorsChangedStream().subscribe(e => (event = e));
+
+ service.markReuseResults([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getOperatorsMarkedForReuseResult().has(mockScanPredicate.operatorID)).toBeTruthy();
+ expect(event?.newReuseCacheOps).toContain(mockScanPredicate.operatorID);
+
+ service.removeMarkReuseResults([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getOperatorsMarkedForReuseResult().has(mockScanPredicate.operatorID)).toBeFalsy();
+ expect(event?.newUnreuseCacheOps).toContain(mockScanPredicate.operatorID);
+ });
+
+ it("should set and unset operators for viewing results", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ let event: { newViewResultOps: readonly string[]; newUnviewResultOps: readonly string[] } | undefined;
+ texeraGraph.getViewResultOperatorsChangedStream().subscribe(e => (event = e));
+
+ service.setViewOperatorResults([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getOperatorsToViewResult().has(mockScanPredicate.operatorID)).toBeTruthy();
+ expect(event?.newViewResultOps).toContain(mockScanPredicate.operatorID);
+
+ service.unsetViewOperatorResults([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getOperatorsToViewResult().has(mockScanPredicate.operatorID)).toBeFalsy();
+ expect(event?.newUnviewResultOps).toContain(mockScanPredicate.operatorID);
+ });
+
+ it("should highlight and unhighlight ports honoring multiselect mode", () => {
+ const jointGraphWrapper = service.getJointGraphWrapper();
+ const portA: LogicalPort = { operatorID: mockScanPredicate.operatorID, portID: "output-0" };
+ const portB: LogicalPort = { operatorID: mockSentimentPredicate.operatorID, portID: "input-0" };
+
+ // multiselect on -> both ports stay highlighted
+ service.highlightPorts(true, portA);
+ service.highlightPorts(true, portB);
+ expect(jointGraphWrapper.getCurrentHighlightedPortIDs().length).toEqual(2);
+
+ service.unhighlightPorts(portA, portB);
+ expect(jointGraphWrapper.getCurrentHighlightedPortIDs().length).toEqual(0);
+
+ // multiselect off -> highlighting a new port replaces the previous one
+ service.highlightPorts(false, portA);
+ service.highlightPorts(false, portB);
+ expect(jointGraphWrapper.getCurrentHighlightedPortIDs()).toEqual([portB]);
+ });
+
+ it("should highlight mixed operator and comment-box elements with multiselect", () => {
+ const jointGraphWrapper = service.getJointGraphWrapper();
+ service.addOperator(mockScanPredicate, mockPoint);
+ const commentBox = { ...mockCommentBox, commentBoxID: "commentBox-hl" };
+ service.addCommentBox(commentBox);
+
+ service.highlightElements(true, mockScanPredicate.operatorID, commentBox.commentBoxID);
+
+ expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toContain(mockScanPredicate.operatorID);
+ expect(jointGraphWrapper.getCurrentHighlightedCommentBoxIDs()).toContain(commentBox.commentBoxID);
+ });
+
+ it("should update an operator's version in the graph", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ service.setOperatorVersion(mockScanPredicate.operatorID, "scan-v2");
+ expect(texeraGraph.getOperator(mockScanPredicate.operatorID).operatorVersion).toEqual("scan-v2");
+ });
+
+ it("should set a port property on an existing port and throw on a missing one", () => {
+ service.addOperator(mockSentimentPredicate, mockPoint);
+ const port: LogicalPort = { operatorID: mockSentimentPredicate.operatorID, portID: "input-0" };
+ const portProperty = { partitionInfo: { type: "none" }, dependencies: [] };
+
+ service.setPortProperty(port, portProperty);
+ expect(texeraGraph.getPortDescription(port)?.partitionRequirement).toEqual({ type: "none" });
+
+ const missingPort: LogicalPort = { operatorID: mockSentimentPredicate.operatorID, portID: "input-99" };
+ expect(() => service.setPortProperty(missingPort, portProperty)).toThrowError(new RegExp("does not exist"));
+ });
+
+ it("should compute the top-left position across all operators", () => {
+ service.addOperator(mockScanPredicate, { x: 300, y: 400 });
+ service.addOperator(mockResultPredicate, { x: 100, y: 250 });
+
+ service.calculateTopLeftOperatorPosition();
+
+ expect(service.getCenterPoint()).toEqual({ x: 100, y: 250 });
+ });
});