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-6829-9b91109f85c0f985debeffa51f7fbfc10e210716 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 64051d7e8fcbe1160a2c10afea2db579501f5991 Author: Meng Wang <[email protected]> AuthorDate: Thu Jul 23 17:11:13 2026 -0700 test(frontend): cover WorkflowEditorComponent connection validation, clipboard, and delete paths (#6829) ### What changes were proposed in this PR? Extends the existing `WorkflowEditorComponent` spec with the file's non-rendering slice — connection validation, the clipboard handlers, and the delete / select-all paths the keyboard tests never reach. The paper-event and pointer handlers stay out of scope (they need real `dia.Paper` geometry / browser mode). 12 new tests take the component from 68.6% to 74.0% statement coverage (+31 lines) and bring every targeted method to 100%: - `validateJointOperatorConnection` — rejects a link drawn out of an input port and one dropped onto an output port; delegates an output→input pair to the operator-level check (proved by re-running once the link exists). - `validateOperatorConnection` guards — self-connection, a missing port on either end, and an endpoint that is not an operator. - `deleteElements` — highlighted links and comment boxes are deleted, not just operators. - `handleElementSelectAll` — ⌘/Ctrl+A also highlights links and comment boxes. - `handleElementCopy` / `handleElementCut` / `handleElementPaste` — copy caches the selection, cut caches then deletes it, paste delegates to the operator menu. - `handleDisableOperator` — repaints the operator on disable and on re-enable. No production code was changed. ### Any related issues, documentation, discussions? Closes #6827. ### How was this PR tested? `ng test --watch=false --include src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts` — 57 passed (45 existing + 12 new), run 3× for determinism. The failure path was verified by breaking an assertion (red, non-zero exit), and eslint/prettier are clean. Coverage was measured with `--coverage` before and after to confirm each targeted method reached 100%. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../workflow-editor.component.spec.ts | 264 +++++++++++++++++++++ 1 file changed, 264 insertions(+) diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts index 88332d82c5..d721f10a8d 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts @@ -58,6 +58,7 @@ import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.c import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; import { MockComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service"; import { commonTestProviders } from "../../../common/testing/test-utils"; +import { OperatorMenuService } from "../../service/operator-menu/operator-menu.service"; describe("WorkflowEditorComponent", () => { /** @@ -1249,5 +1250,268 @@ describe("WorkflowEditorComponent", () => { expect(element.attr(`.${operatorAgentActionProgressClass}/visibility`)).toEqual("hidden"); }); }); + + /** + * The editor's non-rendering logic: connection validation, the clipboard + * handlers, and the delete / select-all paths that the keyboard tests above + * never reach (links and comment boxes). None of these need pointer geometry, + * so they run under jsdom. + */ + describe("connection validation, clipboard, and delete/select-all", () => { + /** A port magnet as JointJS hands it to validateConnection. */ + function magnet(attributes: Record<string, string>): SVGElement { + const element = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value)); + return element as unknown as SVGElement; + } + + function validateJointConnection( + sourceOperatorID: string, + sourceMagnet: SVGElement | undefined, + targetOperatorID: string, + targetMagnet: SVGElement | undefined + ): boolean { + const cellView = (id: string) => ({ model: { id } }) as unknown as joint.dia.CellView; + return component["validateJointOperatorConnection"]( + cellView(sourceOperatorID), + sourceMagnet, + cellView(targetOperatorID), + targetMagnet, + "target" as joint.dia.LinkEnd, + {} as joint.dia.LinkView + ); + } + + /** These handlers only run while the body has focus (i.e. no input is being edited). */ + function dispatchOnBody(event: Event): void { + (document.activeElement as HTMLElement)?.blur(); + document.dispatchEvent(event); + fixture.detectChanges(); + } + + describe("validateJointOperatorConnection", () => { + beforeEach(() => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockSentimentPredicate, mockPoint); + }); + + it("rejects a link drawn out of an input port", () => { + expect( + validateJointConnection( + mockScanPredicate.operatorID, + magnet({ "port-group": "in", port: "input-0" }), + mockSentimentPredicate.operatorID, + magnet({ "port-group": "in", port: "input-0" }) + ) + ).toBe(false); + }); + + it("rejects a link dropped onto an output port", () => { + expect( + validateJointConnection( + mockScanPredicate.operatorID, + magnet({ "port-group": "out", port: "output-0" }), + mockSentimentPredicate.operatorID, + magnet({ "port-group": "out", port: "output-0" }) + ) + ).toBe(false); + }); + + it("delegates an output-to-input pair to the operator-level validation", () => { + const outMagnet = () => magnet({ "port-group": "out", port: "output-0" }); + const inMagnet = () => magnet({ "port-group": "in", port: "input-0" }); + + expect( + validateJointConnection( + mockScanPredicate.operatorID, + outMagnet(), + mockSentimentPredicate.operatorID, + inMagnet() + ) + ).toBe(true); + + // Once the link exists the same pair is rejected — proving the call is + // really delegated to validateOperatorConnection rather than hardcoded. + workflowActionService.addLink(mockScanSentimentLink); + expect( + validateJointConnection( + mockScanPredicate.operatorID, + outMagnet(), + mockSentimentPredicate.operatorID, + inMagnet() + ) + ).toBe(false); + }); + }); + + describe("validateOperatorConnection guards", () => { + beforeEach(() => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockSentimentPredicate, mockPoint); + }); + + it("rejects a connection from an operator to itself", () => { + expect( + component["validateOperatorConnection"]( + mockScanPredicate.operatorID, + "output-0", + mockScanPredicate.operatorID, + "input-0" + ) + ).toBe(false); + }); + + it("rejects a connection that is missing a port on either end", () => { + expect( + component["validateOperatorConnection"]( + mockScanPredicate.operatorID, + undefined, + mockSentimentPredicate.operatorID, + "input-0" + ) + ).toBe(false); + expect( + component["validateOperatorConnection"]( + mockScanPredicate.operatorID, + "output-0", + mockSentimentPredicate.operatorID, + null + ) + ).toBe(false); + }); + + it("rejects a connection whose endpoint is not an operator", () => { + expect( + component["validateOperatorConnection"]( + "not-an-operator", + "output-0", + mockSentimentPredicate.operatorID, + "input-0" + ) + ).toBe(false); + expect( + component["validateOperatorConnection"]( + mockScanPredicate.operatorID, + "output-0", + "not-an-operator", + "input-0" + ) + ).toBe(false); + }); + }); + + describe("delete", () => { + it("deletes highlighted links and comment boxes, not just operators", () => { + const texeraGraph = workflowActionService.getTexeraGraph(); + const jointGraphWrapper = workflowActionService.getJointGraphWrapper(); + workflowActionService.addOperatorsAndLinks( + [ + { op: mockScanPredicate, pos: mockPoint }, + { op: mockResultPredicate, pos: mockPoint }, + ], + [mockScanResultLink] + ); + workflowActionService.addCommentBox(mockCommentBox); + // multi-select keeps both selections alive: highlighting with it off + // unhighlights everything else first, which would silently drop the link. + jointGraphWrapper.setMultiSelectMode(true); + jointGraphWrapper.unhighlightOperators(...jointGraphWrapper.getCurrentHighlightedOperatorIDs()); + jointGraphWrapper.highlightLinks(mockScanResultLink.linkID); + jointGraphWrapper.highlightCommentBoxes(mockCommentBox.commentBoxID); + + // guard the setup so the assertions below cannot pass vacuously + expect(texeraGraph.hasLinkWithID(mockScanResultLink.linkID)).toBe(true); + expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toEqual([]); + expect(jointGraphWrapper.getCurrentHighlightedLinkIDs()).toContain(mockScanResultLink.linkID); + expect(jointGraphWrapper.getCurrentHighlightedCommentBoxIDs()).toContain(mockCommentBox.commentBoxID); + + dispatchOnBody(new KeyboardEvent("keydown", { key: "Delete" })); + + expect(texeraGraph.hasLinkWithID(mockScanResultLink.linkID)).toBe(false); + expect(texeraGraph.hasCommentBox(mockCommentBox.commentBoxID)).toBe(false); + // the operators were not highlighted, so they survive + expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBe(true); + }); + }); + + describe("select all", () => { + it("highlights links and comment boxes as well as operators", () => { + const jointGraphWrapper = workflowActionService.getJointGraphWrapper(); + workflowActionService.addOperatorsAndLinks( + [ + { op: mockScanPredicate, pos: mockPoint }, + { op: mockResultPredicate, pos: mockPoint }, + ], + [mockScanResultLink] + ); + workflowActionService.addCommentBox(mockCommentBox); + + dispatchOnBody(new KeyboardEvent("keydown", { key: "a", metaKey: true })); + + expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toContain(mockScanPredicate.operatorID); + expect(jointGraphWrapper.getCurrentHighlightedLinkIDs()).toContain(mockScanResultLink.linkID); + expect(jointGraphWrapper.getCurrentHighlightedCommentBoxIDs()).toContain(mockCommentBox.commentBoxID); + }); + }); + + describe("disabled-operator stream", () => { + it("repaints an operator when it is disabled and again when it is re-enabled", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const changeSpy = vi.spyOn(jointUIService, "changeOperatorDisableStatus"); + try { + workflowActionService.disableOperators([mockScanPredicate.operatorID]); + expect(changeSpy).toHaveBeenCalledTimes(1); + + workflowActionService.enableOperators([mockScanPredicate.operatorID]); + expect(changeSpy).toHaveBeenCalledTimes(2); + } finally { + changeSpy.mockRestore(); + } + }); + }); + + describe("clipboard", () => { + let operatorMenu: OperatorMenuService; + + beforeEach(() => { + operatorMenu = TestBed.inject(OperatorMenuService); + workflowActionService.addOperator(mockScanPredicate, mockPoint); + // the copy/cut handlers read the menu's latest highlighted-element snapshot + workflowActionService.getJointGraphWrapper().highlightOperators(mockScanPredicate.operatorID); + }); + + it("caches the highlighted elements on copy", () => { + const saveSpy = vi.spyOn(operatorMenu, "saveHighlightedElements").mockImplementation(() => {}); + try { + dispatchOnBody(new Event("copy")); + expect(saveSpy).toHaveBeenCalledTimes(1); + expect(workflowActionService.getTexeraGraph().hasOperator(mockScanPredicate.operatorID)).toBe(true); + } finally { + saveSpy.mockRestore(); + } + }); + + it("caches and then deletes the highlighted elements on cut", () => { + const saveSpy = vi.spyOn(operatorMenu, "saveHighlightedElements").mockImplementation(() => {}); + try { + dispatchOnBody(new Event("cut")); + expect(saveSpy).toHaveBeenCalledTimes(1); + expect(workflowActionService.getTexeraGraph().hasOperator(mockScanPredicate.operatorID)).toBe(false); + } finally { + saveSpy.mockRestore(); + } + }); + + it("pastes the cached elements on paste", () => { + const pasteSpy = vi.spyOn(operatorMenu, "performPasteOperation").mockImplementation(() => {}); + try { + dispatchOnBody(new Event("paste")); + expect(pasteSpy).toHaveBeenCalledTimes(1); + } finally { + pasteSpy.mockRestore(); + } + }); + }); + }); }); });
