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-7590-321fe45505ecadf3aba184c2caee2b948c0d86dc in repository https://gitbox.apache.org/repos/asf/texera.git
commit c35bdb134cab4126b25c39974be3e29a0a17a672 Author: Xinyuan Lin <[email protected]> AuthorDate: Wed Aug 12 23:33:39 2026 +0000 test(frontend): cover the workflow editor's remaining event handlers (#7590) ### What changes were proposed in this PR? `workflow-editor.component.ts` sat at **79.9% of lines and 59.0% of branches**. The branch number was the real gap: almost half its conditions had only ever been taken one way. Adds 42 tests as a new appended block — appended rather than interleaved so the diff stays off the region PR #6927 touches. | | Before | After | |---|---|---| | Lines | 449/562 (79.9%) | **562/562 (100%)** | | Branches | 138/234 (59.0%) | **203/234 (86.8%)** | Nothing needs a browser, which is worth saying because it looks like it should. The component is almost entirely an event-wiring layer, and both seams were already established in this spec: triggering paper events against a real `CellView`, and pushing directly onto the services' subjects. Covered: the magnet and connection validators, the read-only paper lock, the recovering-state overrides and the transitions out of recovery, region reshape and recolour, blank-canvas panning and window resize, the repaint streams for view-result / reuse-cache / renamed operators and ports, shift-multiselect over links and comment boxes, port highlighting, the link hover tools, cursor presence, and the agent hover labels. ### Verification 49 mutations applied and reverted, production diff confirmed empty each time. Two survived during the build and were dealt with before this was raised — one test was vacuous because the un-guarded path throws inside an rxjs subscriber (reported asynchronously, so the assertion on the unchanged popover still held), and it was rewritten; the other is disclosed below. Three further mutations were then run independently, chosen for failure modes the build had not targeted rather than repeating its list: | Mutation | Result | |---|---| | the recovering override reports `Paused` instead of `Recovering` | red | | a highlighted port keeps the unhighlighted radius | red | | a blank-canvas click no longer clears the selection | red | One of my own probes was a **bad mutation rather than a finding**: rewriting `currentOpenedOperatorID = null` as `= null as any` is semantically identical, so its survival meant nothing. Re-run properly, the handler is pinned. ### Deliberately not included - **Line 354's `throw`** on an unknown transition out of recovering. It fires inside a subscriber, so rxjs reports it via `reportUnhandledError` asynchronously and `expect(...).toThrow()` does not catch it. Chasing one line with a test that leaves a stray unhandled error in the run is not worth it. - **The false arms at lines 726 and 1116** are **dead, not untested**: the stream is pre-filtered to `hasOperator || hasCommentBox`, so inside the non-shift `else` a false `hasOperator` implies `hasCommentBox`. This is why branch coverage stops at 86.8% rather than higher. - **`handleRegionEvents`' position filter** — one mutation on it survives and is genuinely unpinnable from this spec; the test that would have claimed it was removed rather than left overclaiming. No production file is touched. ### Any related issues, documentation, discussions? Closes #7589 ### How was this PR tested? ``` npx ng test --watch=false --include="**/workflow-editor.component.spec.ts" ``` ``` Test Files 1 passed (1) Tests 104 passed (104) ``` 42 new on top of the existing 62. The whole workflow-editor folder (5 spec files, 167 tests) also stays green, so there is no cross-test leakage. `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../workflow-editor.component.spec.ts | 795 ++++++++++++++++++++- 1 file changed, 791 insertions(+), 4 deletions(-) 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 2c8807a9ec..0dbb5d9f38 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 @@ -21,13 +21,17 @@ import { WorkflowActionService } from "../../service/workflow-graph/model/workfl import { UndoRedoService } from "../../service/undo-redo/undo-redo.service"; import { DragDropService } from "../../service/drag-drop/drag-drop.service"; import { WorkflowUtilService } from "../../service/workflow-graph/util/workflow-util.service"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { ValidationWorkflowService } from "../../service/validation/validation-workflow.service"; import { WorkflowEditorComponent } from "./workflow-editor.component"; import { workflowEditorTestImports, workflowEditorTestProviders } from "./workflow-editor.test-utils"; import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service"; import { StubOperatorMetadataService } from "../../service/operator-metadata/stub-operator-metadata.service"; -import { JointUIService, operatorAgentActionProgressClass } from "../../service/joint-ui/joint-ui.service"; +import { + JointUIService, + operatorAgentActionProgressClass, + operatorNameClass, +} from "../../service/joint-ui/joint-ui.service"; import { AgentService, OperatorResultSummary } from "../../service/agent/agent.service"; import { NzModalModule, NzModalService } from "ng-zorro-antd/modal"; import { Overlay } from "@angular/cdk/overlay"; @@ -44,13 +48,13 @@ import { mockSentimentPredicate, } from "../../service/workflow-graph/model/mock-workflow-data"; import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service"; -import { OperatorState } from "../../types/execute-workflow.interface"; +import { ExecutionState, OperatorState } from "../../types/execute-workflow.interface"; import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { OperatorLink, OperatorPredicate } from "../../types/workflow-common.interface"; import { tap } from "rxjs/operators"; import { WorkflowVersionService } from "../../../dashboard/service/user/workflow-version/workflow-version.service"; -import { of } from "rxjs"; +import { config as rxjsConfig, of, Subject } from "rxjs"; import { NzContextMenuService, NzDropDownModule } from "ng-zorro-antd/dropdown"; import { ActivatedRoute, Router } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; @@ -1657,3 +1661,786 @@ describe("WorkflowEditorComponent link breakpoints", () => { expect(show.mock.invocationCallOrder[0]).toBeLessThan(hide.mock.invocationCallOrder[0]); }); }); + +/** + * The rest of the wiring installed by `ngAfterViewInit`: the JointJS paper option + * callbacks, the status / execution / region streams, the pointer, selection and + * port handlers, the agent hover labels, and the operator chat popover. + * + * Link breakpoints are switched on for the whole block because `handleLinkCursorHover` + * only pushes the breakpoint tool onto a hovered link when the flag is set, and the + * two link-tool buttons are only reachable through that tool set. + */ +describe("WorkflowEditorComponent editor wiring", () => { + let fixture: ComponentFixture<WorkflowEditorComponent>; + let component: WorkflowEditorComponent; + let workflowActionService: WorkflowActionService; + let jointUIService: JointUIService; + let dragDropService: DragDropService; + let executeWorkflowService: ExecuteWorkflowService; + let workflowStatusService: WorkflowStatusService; + let agentService: AgentService; + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: workflowEditorTestImports, + providers: workflowEditorTestProviders, + }).compileComponents(); + + // ngAfterViewInit reads both of these once, so they have to be set before the first + // detectChanges: the flag decides whether the breakpoint tool is ever attached, and + // highlighting decides whether the cell-highlight handlers are installed at all. + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ + linkBreakpointEnabled: true, + }); + workflowActionService = TestBed.inject(WorkflowActionService); + workflowActionService.setHighlightingEnabled(true); + jointUIService = TestBed.inject(JointUIService); + dragDropService = TestBed.inject(DragDropService); + executeWorkflowService = TestBed.inject(ExecuteWorkflowService); + workflowStatusService = TestBed.inject(WorkflowStatusService); + agentService = TestBed.inject(AgentService); + + fixture = TestBed.createComponent(WorkflowEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + // mockCommentBox's id is "1", the same as mockScanPredicate's, and a joint graph cannot hold + // two cells under one id — these tests put both on the canvas, so they need a distinct box. + const commentBox = { ...mockCommentBox, commentBoxID: "comment-box-1" }; + + /** A port magnet as JointJS hands it to the paper's validate callbacks. */ + 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; + } + + /** Adds scan -> result and returns the link's id, model and rendered view. */ + function addLinkedPair() { + workflowActionService.addOperatorsAndLinks( + [ + { op: mockScanPredicate, pos: mockPoint }, + { op: mockResultPredicate, pos: mockPoint }, + ], + [mockScanResultLink] + ); + const model = component.paper.getModelById(mockScanResultLink.linkID); + return { linkID: mockScanResultLink.linkID, model, view: model.findView(component.paper) as any }; + } + + /** Adds a placeholder cell per region member, then drives the region-update stream. */ + function addRegions(regions: readonly [number, string[]][]): void { + regions + .flatMap(([, operators]) => operators) + .forEach(operatorID => { + const cell = new joint.shapes.basic.Rect({ position: { x: 0, y: 0 }, size: { width: 80, height: 40 } }); + cell.set("id", operatorID); + component.paper.model.addCell(cell); + }); + (executeWorkflowService as any).regionUpdateStream.next({ regions }); + } + + /** An OperatorStatistics payload in the given state. */ + function statisticsIn(state: OperatorState) { + return { + operatorState: state, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + }; + } + + /** Clicks the chat button of a cell, the way `.chat-button` does. */ + function clickChatButton(cellID: string): void { + const view = component.paper.findViewByModel(cellID); + (component.paper as any).trigger("element:chat", view, new Event("click"), 0, 0); + } + + /** + * A blank:pointerdown starts the paper-pan gesture, which listens on document mousemove + * until a mouseup; fire the mouseup so the listener does not leak into later tests. + */ + function clickBlankCanvas(): void { + (component.paper as any).trigger("blank:pointerdown"); + document.dispatchEvent(new MouseEvent("mouseup")); + } + + describe("paper options", () => { + it("only lets a link start from an output-port magnet", () => { + // validateMagnet is what stops the user dragging a new link out of an input port. + const validateMagnet = (component.paper.options as any).validateMagnet; + const cellView = {} as joint.dia.CellView; + + expect( + validateMagnet.call(component.paper, cellView, magnet({ "port-group": "out" }), new Event("mousedown")) + ).toBe(true); + expect( + validateMagnet.call(component.paper, cellView, magnet({ "port-group": "in" }), new Event("mousedown")) + ).toBe(false); + }); + + it("routes the validateConnection option through the operator-level validation", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockSentimentPredicate, mockPoint); + const cellView = (id: string) => ({ model: { id } }) as unknown as joint.dia.CellView; + const validateConnection = (component.paper.options as any).validateConnection; + const args = () => [ + cellView(mockScanPredicate.operatorID), + magnet({ "port-group": "out", port: "output-0" }), + cellView(mockSentimentPredicate.operatorID), + magnet({ "port-group": "in", port: "input-0" }), + "target", + {} as joint.dia.LinkView, + ]; + + expect(validateConnection.apply(component.paper, args())).toBe(true); + + // Once the link exists the same pair is rejected, which only the delegated + // duplicate-link check knows about. + workflowActionService.addLink(mockScanSentimentLink); + expect(validateConnection.apply(component.paper, args())).toBe(false); + }); + + it("locks the paper and ignores element buttons while workflow modification is disabled", () => { + const texeraGraph = workflowActionService.getTexeraGraph(); + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const view = component.paper.findViewByModel(mockScanPredicate.operatorID); + + workflowActionService.disableWorkflowModification(); + expect((component.paper.options.interactive as any).elementMove).toBe(false); + (component.paper as any).trigger("element:delete", view, new Event("click"), 0, 0); + expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBe(true); + + workflowActionService.enableWorkflowModification(); + // the default option leaves element dragging alone, so the key is simply absent + expect((component.paper.options.interactive as any).elementMove).toBeUndefined(); + (component.paper as any).trigger("element:delete", view, new Event("click"), 0, 0); + expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBe(false); + }); + }); + + describe("execution status streams", () => { + it("forwards each operator's statistics, tagging which end of the graph it sits on", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); // no input ports -> source + workflowActionService.addOperator(mockResultPredicate, mockPoint); // no output ports -> sink + const changeStatistics = vi.spyOn(jointUIService, "changeOperatorStatistics"); + + (workflowStatusService as any).statusSubject.next({ + [mockScanPredicate.operatorID]: statisticsIn(OperatorState.Running), + [mockResultPredicate.operatorID]: statisticsIn(OperatorState.Completed), + }); + + expect(changeStatistics).toHaveBeenCalledWith( + component.paper, + mockScanPredicate.operatorID, + statisticsIn(OperatorState.Running), + true, + false + ); + expect(changeStatistics).toHaveBeenCalledWith( + component.paper, + mockResultPredicate.operatorID, + statisticsIn(OperatorState.Completed), + false, + true + ); + }); + + it("overrides the reported state with Recovering while the execution is recovering", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + vi.spyOn(executeWorkflowService, "getExecutionState").mockReturnValue({ + state: ExecutionState.Recovering, + } as any); + const changeStatistics = vi.spyOn(jointUIService, "changeOperatorStatistics"); + + (workflowStatusService as any).statusSubject.next({ + [mockScanPredicate.operatorID]: statisticsIn(OperatorState.Running), + }); + + expect(changeStatistics).toHaveBeenCalledWith( + component.paper, + mockScanPredicate.operatorID, + expect.objectContaining({ operatorState: OperatorState.Recovering }), + true, + false + ); + }); + + it("does not invent statistics for an operator missing from the status payload", () => { + // The isDefined guard matters most while recovering: without it the operator would be + // handed a synthesized `{ operatorState: Recovering }` instead of nothing at all. + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockResultPredicate, mockPoint); + vi.spyOn(executeWorkflowService, "getExecutionState").mockReturnValue({ + state: ExecutionState.Recovering, + } as any); + const changeStatistics = vi.spyOn(jointUIService, "changeOperatorStatistics"); + + (workflowStatusService as any).statusSubject.next({ + [mockScanPredicate.operatorID]: statisticsIn(OperatorState.Running), + }); + + expect(changeStatistics).toHaveBeenCalledWith( + component.paper, + mockResultPredicate.operatorID, + undefined, + false, + true + ); + }); + + it("repaints every operator with the state the execution recovered into", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockResultPredicate, mockPoint); + const changeState = vi.spyOn(jointUIService, "changeOperatorState"); + const recoverInto = (current: ExecutionState) => + (executeWorkflowService as any).executionStateStream.next({ + previous: { state: ExecutionState.Recovering }, + current: { state: current }, + }); + + recoverInto(ExecutionState.Paused); + expect(changeState).toHaveBeenCalledWith(component.paper, mockScanPredicate.operatorID, OperatorState.Paused); + expect(changeState).toHaveBeenCalledWith(component.paper, mockResultPredicate.operatorID, OperatorState.Paused); + + recoverInto(ExecutionState.Completed); + expect(changeState).toHaveBeenCalledWith(component.paper, mockScanPredicate.operatorID, OperatorState.Completed); + + recoverInto(ExecutionState.Running); + expect(changeState).toHaveBeenCalledWith(component.paper, mockScanPredicate.operatorID, OperatorState.Running); + }); + + it("refuses to guess a color for an unrecognized transition out of recovering", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + // By default rxjs reports an error thrown inside a subscriber asynchronously, which would + // slip past the assertion and surface as an unhandled error; this flag makes it rethrow + // out of the next() call instead. Restored in the finally so later specs are unaffected. + rxjsConfig.useDeprecatedSynchronousErrorHandling = true; + try { + expect(() => + (executeWorkflowService as any).executionStateStream.next({ + previous: { state: ExecutionState.Recovering }, + current: { state: ExecutionState.Terminated }, + }) + ).toThrowError("unknown state transition from recovering state: Terminated"); + } finally { + rxjsConfig.useDeprecatedSynchronousErrorHandling = false; + } + }); + + it("leaves operator colors alone for a transition that did not come out of recovering", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const changeState = vi.spyOn(jointUIService, "changeOperatorState"); + + (executeWorkflowService as any).executionStateStream.next({ + previous: { state: ExecutionState.Running }, + current: { state: ExecutionState.Completed }, + }); + + expect(changeState).not.toHaveBeenCalled(); + }); + }); + + describe("region overlays", () => { + it("reshapes the region drawn around an operator that moved", () => { + // Two regions so the handler's filter is exercised on both sides. Nothing can be asserted + // about the untouched one: its outline is recomputed from its own, unmoved operator, so a + // filter that matched every region would produce the identical path anyway. + addRegions([ + [1, ["region-op-a"]], + [2, ["region-op-b"]], + ]); + const movedRegion = component.paper.getModelById("region-1"); + const before = movedRegion.attr("body/d"); + expect(before).toBeTruthy(); + + (<joint.dia.Element>component.paper.getModelById("region-op-a")).translate(150, 0); + + expect(movedRegion.attr("body/d")).not.toEqual(before); + }); + + it("tints a region with the color of the phase it reports", () => { + addRegions([[1, ["region-op-a"]]]); + const region = component.paper.getModelById("region-1"); + + (executeWorkflowService as any).regionStateStream.next({ id: 1, state: "ExecutingDependeePortsPhase" }); + expect(region.attr("body/fill")).toEqual("rgba(33,150,243,0.2)"); + + (executeWorkflowService as any).regionStateStream.next({ id: 1, state: "Completed" }); + expect(region.attr("body/fill")).toEqual("rgba(76,175,80,0.2)"); + }); + }); + + describe("paper viewport", () => { + it("pans the paper by the pointer delta scaled back into paper coordinates", () => { + // At 50% zoom a 10px screen drag has to move the paper 20 paper-units, otherwise the + // canvas drifts away from the cursor. + workflowActionService.getJointGraphWrapper().setZoomProperty(0.5); + const before = component.paper.translate(); + (component.paper as any).trigger("blank:pointerdown"); + + const drag = new MouseEvent("mousemove"); + Object.defineProperty(drag, "movementX", { value: 10 }); + Object.defineProperty(drag, "movementY", { value: -6 }); + document.dispatchEvent(drag); + document.dispatchEvent(new MouseEvent("mouseup")); + + expect(component.paper.translate().tx).toEqual(before.tx + 20); + expect(component.paper.translate().ty).toEqual(before.ty - 12); + }); + + it("resizes the paper to the wrapper's dimensions on a window resize", fakeAsync(() => { + // The paper follows the wrapper, not the editor: the wrapper is what shrinks when the + // result panel opens. + Object.defineProperty(component.editorWrapper, "offsetWidth", { value: 321, configurable: true }); + Object.defineProperty(component.editorWrapper, "offsetHeight", { value: 123, configurable: true }); + const setDimensions = vi.spyOn(component.paper, "setDimensions"); + + window.dispatchEvent(new Event("resize")); + tick(30); // the handler audits the resize stream down to one event every 30ms + + expect(setDimensions).toHaveBeenCalledWith(321, 123); + })); + }); + + describe("operator badge streams", () => { + it("repaints the result icon when an operator is marked and unmarked for viewing", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const changeViewResult = vi.spyOn(jointUIService, "changeOperatorViewResultStatus"); + + workflowActionService.setViewOperatorResults([mockScanPredicate.operatorID]); + expect(changeViewResult).toHaveBeenLastCalledWith( + component.paper, + expect.objectContaining({ operatorID: mockScanPredicate.operatorID, viewResult: true }), + true + ); + + workflowActionService.unsetViewOperatorResults([mockScanPredicate.operatorID]); + expect(changeViewResult).toHaveBeenLastCalledWith( + component.paper, + expect.objectContaining({ operatorID: mockScanPredicate.operatorID, viewResult: false }), + false + ); + }); + + it("repaints the cache icon when an operator is marked and unmarked for reuse", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const changeReuseCache = vi.spyOn(jointUIService, "changeOperatorReuseCacheStatus"); + + workflowActionService.markReuseResults([mockScanPredicate.operatorID]); + expect(changeReuseCache).toHaveBeenLastCalledWith( + component.paper, + expect.objectContaining({ operatorID: mockScanPredicate.operatorID, markedForReuse: true }) + ); + + workflowActionService.removeMarkReuseResults([mockScanPredicate.operatorID]); + expect(changeReuseCache).toHaveBeenLastCalledWith( + component.paper, + expect.objectContaining({ operatorID: mockScanPredicate.operatorID, markedForReuse: false }) + ); + }); + + it("renders a renamed operator's display name on its joint element", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const texeraGraph = workflowActionService.getTexeraGraph(); + + (texeraGraph as any).operatorDisplayNameChangedSubject.next({ + operatorID: mockScanPredicate.operatorID, + newDisplayName: "Renamed Scan", + }); + + expect(component.paper.getModelById(mockScanPredicate.operatorID).attr(`.${operatorNameClass}/text`)).toEqual( + "Renamed Scan" + ); + }); + + it("renders a renamed port's display name on the port label", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const texeraGraph = workflowActionService.getTexeraGraph(); + const element = <joint.dia.Element>workflowActionService.getJointGraph().getCell(mockScanPredicate.operatorID); + + texeraGraph.portDisplayNameChangedSubject.next({ + operatorID: mockScanPredicate.operatorID, + portID: "output-0", + newDisplayName: "results", + }); + + expect(element.portProp("output-0", "attrs/.port-label/text")).toEqual("results"); + }); + }); + + describe("selection and highlight handlers", () => { + it("opens the result panel when an operator is double-clicked, but not for a link", () => { + const { view: linkView } = addLinkedPair(); + const opened: boolean[] = []; + workflowActionService.resultPanelOpen$.subscribe(value => opened.push(value)); + const operatorView = component.paper.findViewByModel(mockScanPredicate.operatorID); + + (component.paper as any).trigger("cell:pointerdblclick", operatorView, { shiftKey: false }); + expect(opened).toEqual([true]); + + // a link is not an element, so the handler skips it entirely + (component.paper as any).trigger("cell:pointerdblclick", linkView, { shiftKey: false }); + expect(opened).toEqual([true]); + }); + + it("highlights the link running between two shift-selected operators", () => { + const { linkID } = addLinkedPair(); + const wrapper = workflowActionService.getJointGraphWrapper(); + wrapper.unhighlightOperators(...wrapper.getCurrentHighlightedOperatorIDs()); + const scanView = component.paper.findViewByModel(mockScanPredicate.operatorID); + const resultView = component.paper.findViewByModel(mockResultPredicate.operatorID); + + (component.paper as any).trigger("cell:pointerdown", scanView, { shiftKey: true }); + (component.paper as any).trigger("cell:pointerdown", resultView, { shiftKey: true }); + + expect(wrapper.getCurrentHighlightedLinkIDs()).toEqual([linkID]); + }); + + it("toggles a comment box in and out of a shift-selection", () => { + const wrapper = workflowActionService.getJointGraphWrapper(); + workflowActionService.addCommentBox(commentBox); + wrapper.unhighlightCommentBoxes(...wrapper.getCurrentHighlightedCommentBoxIDs()); + const view = component.paper.findViewByModel(commentBox.commentBoxID); + + (component.paper as any).trigger("cell:pointerdown", view, { shiftKey: true }); + expect(wrapper.getCurrentHighlightedCommentBoxIDs()).toEqual([commentBox.commentBoxID]); + + (component.paper as any).trigger("cell:pointerdown", view, { shiftKey: true }); + expect(wrapper.getCurrentHighlightedCommentBoxIDs()).toEqual([]); + }); + + it("replaces the whole selection when a comment box is clicked without shift", () => { + const wrapper = workflowActionService.getJointGraphWrapper(); + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addCommentBox(commentBox); + wrapper.unhighlightCommentBoxes(...wrapper.getCurrentHighlightedCommentBoxIDs()); + wrapper.highlightOperators(mockScanPredicate.operatorID); + const view = component.paper.findViewByModel(commentBox.commentBoxID); + + (component.paper as any).trigger("cell:pointerdown", view, { shiftKey: false }); + + expect(wrapper.getCurrentHighlightedCommentBoxIDs()).toEqual([commentBox.commentBoxID]); + expect(wrapper.getCurrentHighlightedOperatorIDs()).toEqual([]); + }); + + it("grows a port when it is highlighted and shrinks it back when it is not", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const element = <joint.dia.Element>workflowActionService.getJointGraph().getCell(mockScanPredicate.operatorID); + const port = { operatorID: mockScanPredicate.operatorID, portID: "output-0" }; + + workflowActionService.highlightPorts(false, port); + expect(element.portProp("output-0", "attrs/.port-body/r")).toEqual(8); + + workflowActionService.unhighlightPorts(port); + expect(element.portProp("output-0", "attrs/.port-body/r")).toEqual(5); + expect(element.portProp("output-0", "attrs/.port-body/stroke")).toEqual("none"); + }); + + it("toggles a port in and out of a shift-selection when its magnet is clicked", () => { + const wrapper = workflowActionService.getJointGraphWrapper(); + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const view = component.paper.findViewByModel(mockScanPredicate.operatorID); + const portMagnet = { getAttribute: (name: string) => (name === "port" ? "output-0" : null) }; + const shiftClickPort = () => + (component.paper as any).trigger("element:magnet:pointerclick", view, { shiftKey: true }, portMagnet); + + shiftClickPort(); + expect(wrapper.getCurrentHighlightedPortIDs()).toContainEqual({ + operatorID: mockScanPredicate.operatorID, + portID: "output-0", + }); + + shiftClickPort(); + expect(wrapper.getCurrentHighlightedPortIDs()).toEqual([]); + }); + + it("outlines an operator suggested as a drop target and clears the outline afterwards", () => { + const wrapper = workflowActionService.getJointGraphWrapper(); + workflowActionService.addOperator(mockScanPredicate, mockPoint); + wrapper.unhighlightOperators(...wrapper.getCurrentHighlightedOperatorIDs()); + const view = component.paper.findViewByModel(mockScanPredicate.operatorID); + const outlines = () => view.$el.children(".joint-highlight-stroke").length; + expect(outlines()).toEqual(0); + + (dragDropService as any).operatorSuggestionHighlightStream.next(mockScanPredicate.operatorID); + expect(outlines()).toEqual(1); + + (dragDropService as any).operatorSuggestionUnhighlightStream.next(mockScanPredicate.operatorID); + expect(outlines()).toEqual(0); + }); + + it("deletes a comment box when its delete button fires", () => { + const texeraGraph = workflowActionService.getTexeraGraph(); + workflowActionService.addCommentBox(commentBox); + const view = component.paper.findViewByModel(commentBox.commentBoxID); + + (component.paper as any).trigger("element:delete", view, new Event("click"), 0, 0); + + expect(texeraGraph.hasCommentBox(commentBox.commentBoxID)).toBe(false); + }); + + it("unfolds the clicked operator's details and folds the one opened before it", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockSentimentPredicate, mockPoint); + const deleteButton = (operatorID: string) => + component.paper.getModelById(operatorID).attr(".delete-button/visibility"); + + (component.paper as any).trigger( + "element:pointerdown", + component.paper.findViewByModel(mockScanPredicate.operatorID), + {} + ); + expect(deleteButton(mockScanPredicate.operatorID)).toEqual("visible"); + + (component.paper as any).trigger( + "element:pointerdown", + component.paper.findViewByModel(mockSentimentPredicate.operatorID), + {} + ); + expect(deleteButton(mockScanPredicate.operatorID)).toEqual("hidden"); + expect(deleteButton(mockSentimentPredicate.operatorID)).toEqual("visible"); + }); + + it("unfolds operator details on a right-click too", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockSentimentPredicate, mockPoint); + const deleteButton = (operatorID: string) => + component.paper.getModelById(operatorID).attr(".delete-button/visibility"); + + (component.paper as any).trigger( + "element:contextmenu", + component.paper.findViewByModel(mockScanPredicate.operatorID), + {} + ); + expect(deleteButton(mockScanPredicate.operatorID)).toEqual("visible"); + + (component.paper as any).trigger( + "element:contextmenu", + component.paper.findViewByModel(mockSentimentPredicate.operatorID), + {} + ); + expect(deleteButton(mockScanPredicate.operatorID)).toEqual("hidden"); + expect(deleteButton(mockSentimentPredicate.operatorID)).toEqual("visible"); + }); + + it("highlights a right-clicked link so the context menu acts on it", () => { + const { linkID, view } = addLinkedPair(); + const wrapper = workflowActionService.getJointGraphWrapper(); + + (component.paper as any).trigger("link:contextmenu", view, {}); + + expect(wrapper.getCurrentHighlightedLinkIDs()).toEqual([linkID]); + }); + + it("folds the open operator's details when the blank canvas is clicked", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const deleteButton = () => + component.paper.getModelById(mockScanPredicate.operatorID).attr(".delete-button/visibility"); + (component.paper as any).trigger( + "element:pointerdown", + component.paper.findViewByModel(mockScanPredicate.operatorID), + {} + ); + expect(deleteButton()).toEqual("visible"); + + clickBlankCanvas(); + + expect(deleteButton()).toEqual("hidden"); + }); + }); + + describe("link tools", () => { + /** Hovers the link so handleLinkCursorHover attaches the tool set, and returns the tools. */ + function hoverLink(view: any): any[] { + (component.paper as any).trigger("link:mouseenter", view, new Event("mouseenter"), 0, 0); + return view._toolsView.tools; + } + + it("offers both the remove and the breakpoint tool when the cursor enters a link", () => { + const { view } = addLinkedPair(); + + expect(hoverLink(view).map(tool => tool.name)).toEqual(["remove-button", "info-button"]); + }); + + it("deletes the link when the remove tool is activated", () => { + const texeraGraph = workflowActionService.getTexeraGraph(); + const { linkID, view } = addLinkedPair(); + const removeTool = hoverLink(view).find(tool => tool.name === "remove-button"); + + removeTool.options.action.call(removeTool, new Event("click"), view); + + expect(texeraGraph.hasLinkWithID(linkID)).toBe(false); + }); + + it("highlights the link when the breakpoint tool is activated", () => { + const { linkID, view } = addLinkedPair(); + const wrapper = workflowActionService.getJointGraphWrapper(); + wrapper.unhighlightElements(wrapper.getCurrentHighlights()); + const breakpointTool = hoverLink(view).find(tool => tool.name === "info-button"); + + breakpointTool.options.action.call(breakpointTool, new Event("click"), view); + + expect(wrapper.getCurrentHighlightedLinkIDs()).toEqual([linkID]); + }); + }); + + describe("shared-editing presence", () => { + it("publishes the cursor position and the enter/leave presence flag", () => { + const texeraGraph = workflowActionService.getTexeraGraph(); + const updateAwareness = vi.spyOn(texeraGraph, "updateSharedModelAwareness"); + // the real clientToLocalPoint needs SVGGraphicsElement.getScreenCTM, which jsdom lacks; + // stubbing it still pins that the client coordinates go through the paper's conversion + const toLocalPoint = vi.spyOn(component.paper, "clientToLocalPoint").mockReturnValue({ x: 7, y: 9 } as any); + + component.editor.dispatchEvent(new MouseEvent("mousemove", { clientX: 40, clientY: 60 })); + expect(toLocalPoint).toHaveBeenCalledWith({ x: 40, y: 60 }); + expect(updateAwareness).toHaveBeenLastCalledWith("userCursor", { x: 7, y: 9 }); + + component.editor.dispatchEvent(new MouseEvent("mouseenter")); + expect(updateAwareness).toHaveBeenLastCalledWith("isActive", true); + + component.editor.dispatchEvent(new MouseEvent("mouseleave")); + expect(updateAwareness).toHaveBeenLastCalledWith("isActive", false); + }); + }); + + describe("agent hover labels", () => { + const agentName = "Agent One"; + let hovered: Subject<{ viewedOperatorIds: string[]; addedOperatorIds: string[]; modifiedOperatorIds: string[] }>; + + beforeEach(() => { + hovered = new Subject(); + // getAllAgents is an HTTP call that never resolves under HttpClientTestingModule, so the + // per-agent hover subscriptions are only reachable by standing in for it. + vi.spyOn(agentService, "getAllAgents").mockReturnValue(of([{ id: "agent-1", name: agentName } as any])); + vi.spyOn(agentService, "getHoveredMessageOperatorsObservable").mockReturnValue(hovered.asObservable()); + // an agent change re-runs the setup, which is what picks up the stubs above + (agentService as any).agentChangeSubject.next(); + }); + + it("labels the viewed, added and modified operators of the hovered message", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockSentimentPredicate, mockPoint); + workflowActionService.addOperator(mockResultPredicate, mockPoint); + const showLabel = vi.spyOn(jointUIService, "showAgentActionLabel"); + + hovered.next({ + viewedOperatorIds: [mockScanPredicate.operatorID], + addedOperatorIds: [mockSentimentPredicate.operatorID], + modifiedOperatorIds: [mockResultPredicate.operatorID], + }); + + expect(showLabel).toHaveBeenCalledWith(component.paper, mockScanPredicate.operatorID, "viewed", agentName); + expect(showLabel).toHaveBeenCalledWith(component.paper, mockSentimentPredicate.operatorID, "added", agentName); + expect(showLabel).toHaveBeenCalledWith(component.paper, mockResultPredicate.operatorID, "modified", agentName); + expect(showLabel).toHaveBeenCalledTimes(3); + }); + + it("clears the labels of the previous hover and skips ids that left the canvas", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const labelVisibility = () => + component.paper + .getModelById(mockScanPredicate.operatorID) + .attr(`.${operatorAgentActionProgressClass}/visibility`); + hovered.next({ + viewedOperatorIds: [mockScanPredicate.operatorID], + addedOperatorIds: [], + modifiedOperatorIds: [], + }); + expect(labelVisibility()).toEqual("visible"); + const showLabel = vi.spyOn(jointUIService, "showAgentActionLabel"); + + hovered.next({ + viewedOperatorIds: ["deleted-operator"], + addedOperatorIds: ["deleted-operator"], + modifiedOperatorIds: ["deleted-operator"], + }); + + expect(labelVisibility()).toEqual("hidden"); + expect(showLabel).not.toHaveBeenCalled(); + }); + }); + + describe("operator chat popover", () => { + beforeEach(() => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + }); + + it("toggles the popover shut when the same operator's chat button is clicked again", () => { + clickChatButton(mockScanPredicate.operatorID); + expect(component.chatPopoverOperator?.operatorId).toEqual(mockScanPredicate.operatorID); + + clickChatButton(mockScanPredicate.operatorID); + + expect(component.chatPopoverOperator).toBeNull(); + }); + + it("leaves the popover alone when the chat event arrives from a cell that is not an operator", () => { + workflowActionService.addCommentBox(commentBox); + clickChatButton(mockScanPredicate.operatorID); + const getOperator = vi.spyOn(workflowActionService.getTexeraGraph(), "getOperator"); + + clickChatButton(commentBox.commentBoxID); + + // The popover surviving is not enough on its own: without the guard the handler reads the + // cell as an operator and throws, which rxjs reports asynchronously and the popover keeps + // its old value anyway. Pin the bail-out itself. + expect(getOperator).not.toHaveBeenCalled(); + expect(component.chatPopoverOperator?.operatorId).toEqual(mockScanPredicate.operatorID); + }); + + it("closes the popover when the blank canvas is clicked", () => { + clickChatButton(mockScanPredicate.operatorID); + expect(component.chatPopoverOperator).not.toBeNull(); + + clickBlankCanvas(); + + expect(component.chatPopoverOperator).toBeNull(); + }); + + it("keeps the popover anchored to its operator when the operator is dragged", () => { + clickChatButton(mockScanPredicate.operatorID); + const before = { ...component.chatPopoverOperator!.position }; + + (<joint.dia.Element>component.paper.getModelById(mockScanPredicate.operatorID)).translate(60, 30); + + expect(component.chatPopoverOperator!.position).toEqual({ x: before.x + 60, y: before.y + 30 }); + }); + + it("rescales the popover anchor when the canvas is zoomed", () => { + clickChatButton(mockScanPredicate.operatorID); + const before = { ...component.chatPopoverOperator!.position }; + + workflowActionService.getJointGraphWrapper().setZoomProperty(0.5); + + // the anchor is a screen position, so it follows the zoom; the trailing 40px that clears + // the operator's display name is added afterwards and is not scaled + expect(component.chatPopoverOperator!.position).toEqual({ + x: before.x * 0.5, + y: (before.y - 40) * 0.5 + 40, + }); + }); + + it("reports no anchor for an operator that is not on the paper", () => { + expect((component as any).getOperatorChatPopoverPosition("not-on-the-paper")).toBeNull(); + }); + + it("re-renders only while the popover is open when new agent summaries arrive", () => { + const detectChanges = vi.spyOn((component as any).changeDetectorRef, "detectChanges"); + + (agentService as any).operatorResultSummariesSubject.next(new Map()); + expect(detectChanges).not.toHaveBeenCalled(); + + clickChatButton(mockScanPredicate.operatorID); + detectChanges.mockClear(); + (agentService as any).operatorResultSummariesSubject.next(new Map()); + + expect(detectChanges).toHaveBeenCalledTimes(1); + }); + }); +});
