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/08/12 23:34:01 UTC
(texera) branch gh-readonly-queue/main/pr-7590-321fe45505ecadf3aba184c2caee2b948c0d86dc created (now c35bdb134c)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-7590-321fe45505ecadf3aba184c2caee2b948c0d86dc
in repository https://gitbox.apache.org/repos/asf/texera.git
at c35bdb134c test(frontend): cover the workflow editor's remaining event handlers (#7590)
This branch includes the following new commits:
new c35bdb134c test(frontend): cover the workflow editor's remaining event handlers (#7590)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: test(frontend): cover the workflow editor's remaining event handlers (#7590)
Posted by gi...@apache.org.
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 <xi...@uci.edu>
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);
+ });
+ });
+});