This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new c35bdb134c test(frontend): cover the workflow editor's remaining event
handlers (#7590)
c35bdb134c is described below
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);
+ });
+ });
+});