Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.c
import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
import { MockComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service";
import { commonTestProviders } from "../../../common/testing/test-utils";
import { OperatorMenuService } from "../../service/operator-menu/operator-menu.service";

describe("WorkflowEditorComponent", () => {
/**
Expand Down Expand Up @@ -1249,5 +1250,268 @@ describe("WorkflowEditorComponent", () => {
expect(element.attr(`.${operatorAgentActionProgressClass}/visibility`)).toEqual("hidden");
});
});

/**
* The editor's non-rendering logic: connection validation, the clipboard
* handlers, and the delete / select-all paths that the keyboard tests above
* never reach (links and comment boxes). None of these need pointer geometry,
* so they run under jsdom.
*/
describe("connection validation, clipboard, and delete/select-all", () => {
/** A port magnet as JointJS hands it to validateConnection. */
function magnet(attributes: Record<string, string>): SVGElement {
const element = document.createElementNS("http://www.w3.org/2000/svg", "rect");
Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value));
return element as unknown as SVGElement;
}

function validateJointConnection(
sourceOperatorID: string,
sourceMagnet: SVGElement | undefined,
targetOperatorID: string,
targetMagnet: SVGElement | undefined
): boolean {
const cellView = (id: string) => ({ model: { id } }) as unknown as joint.dia.CellView;
return component["validateJointOperatorConnection"](
cellView(sourceOperatorID),
sourceMagnet,
cellView(targetOperatorID),
targetMagnet,
"target" as joint.dia.LinkEnd,
{} as joint.dia.LinkView
);
}

/** These handlers only run while the body has focus (i.e. no input is being edited). */
function dispatchOnBody(event: Event): void {
(document.activeElement as HTMLElement)?.blur();
document.dispatchEvent(event);
fixture.detectChanges();
}

describe("validateJointOperatorConnection", () => {
beforeEach(() => {
workflowActionService.addOperator(mockScanPredicate, mockPoint);
workflowActionService.addOperator(mockSentimentPredicate, mockPoint);
});

it("rejects a link drawn out of an input port", () => {
expect(
validateJointConnection(
mockScanPredicate.operatorID,
magnet({ "port-group": "in", port: "input-0" }),
mockSentimentPredicate.operatorID,
magnet({ "port-group": "in", port: "input-0" })
)
).toBe(false);
});

it("rejects a link dropped onto an output port", () => {
expect(
validateJointConnection(
mockScanPredicate.operatorID,
magnet({ "port-group": "out", port: "output-0" }),
mockSentimentPredicate.operatorID,
magnet({ "port-group": "out", port: "output-0" })
)
).toBe(false);
});

it("delegates an output-to-input pair to the operator-level validation", () => {
const outMagnet = () => magnet({ "port-group": "out", port: "output-0" });
const inMagnet = () => magnet({ "port-group": "in", port: "input-0" });

expect(
validateJointConnection(
mockScanPredicate.operatorID,
outMagnet(),
mockSentimentPredicate.operatorID,
inMagnet()
)
).toBe(true);

// Once the link exists the same pair is rejected — proving the call is
// really delegated to validateOperatorConnection rather than hardcoded.
workflowActionService.addLink(mockScanSentimentLink);
expect(
validateJointConnection(
mockScanPredicate.operatorID,
outMagnet(),
mockSentimentPredicate.operatorID,
inMagnet()
)
).toBe(false);
});
});

describe("validateOperatorConnection guards", () => {
beforeEach(() => {
workflowActionService.addOperator(mockScanPredicate, mockPoint);
workflowActionService.addOperator(mockSentimentPredicate, mockPoint);
});

it("rejects a connection from an operator to itself", () => {
expect(
component["validateOperatorConnection"](
mockScanPredicate.operatorID,
"output-0",
mockScanPredicate.operatorID,
"input-0"
)
).toBe(false);
});

it("rejects a connection that is missing a port on either end", () => {
expect(
component["validateOperatorConnection"](
mockScanPredicate.operatorID,
undefined,
mockSentimentPredicate.operatorID,
"input-0"
)
).toBe(false);
expect(
component["validateOperatorConnection"](
mockScanPredicate.operatorID,
"output-0",
mockSentimentPredicate.operatorID,
null
)
).toBe(false);
});

it("rejects a connection whose endpoint is not an operator", () => {
expect(
component["validateOperatorConnection"](
"not-an-operator",
"output-0",
mockSentimentPredicate.operatorID,
"input-0"
)
).toBe(false);
expect(
component["validateOperatorConnection"](
mockScanPredicate.operatorID,
"output-0",
"not-an-operator",
"input-0"
)
).toBe(false);
});
});

describe("delete", () => {
it("deletes highlighted links and comment boxes, not just operators", () => {
const texeraGraph = workflowActionService.getTexeraGraph();
const jointGraphWrapper = workflowActionService.getJointGraphWrapper();
workflowActionService.addOperatorsAndLinks(
[
{ op: mockScanPredicate, pos: mockPoint },
{ op: mockResultPredicate, pos: mockPoint },
],
[mockScanResultLink]
);
workflowActionService.addCommentBox(mockCommentBox);
// multi-select keeps both selections alive: highlighting with it off
// unhighlights everything else first, which would silently drop the link.
jointGraphWrapper.setMultiSelectMode(true);
jointGraphWrapper.unhighlightOperators(...jointGraphWrapper.getCurrentHighlightedOperatorIDs());
jointGraphWrapper.highlightLinks(mockScanResultLink.linkID);
jointGraphWrapper.highlightCommentBoxes(mockCommentBox.commentBoxID);

// guard the setup so the assertions below cannot pass vacuously
expect(texeraGraph.hasLinkWithID(mockScanResultLink.linkID)).toBe(true);
expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toEqual([]);
expect(jointGraphWrapper.getCurrentHighlightedLinkIDs()).toContain(mockScanResultLink.linkID);
expect(jointGraphWrapper.getCurrentHighlightedCommentBoxIDs()).toContain(mockCommentBox.commentBoxID);

dispatchOnBody(new KeyboardEvent("keydown", { key: "Delete" }));

expect(texeraGraph.hasLinkWithID(mockScanResultLink.linkID)).toBe(false);
expect(texeraGraph.hasCommentBox(mockCommentBox.commentBoxID)).toBe(false);
// the operators were not highlighted, so they survive
expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBe(true);
});
});

describe("select all", () => {
it("highlights links and comment boxes as well as operators", () => {
const jointGraphWrapper = workflowActionService.getJointGraphWrapper();
workflowActionService.addOperatorsAndLinks(
[
{ op: mockScanPredicate, pos: mockPoint },
{ op: mockResultPredicate, pos: mockPoint },
],
[mockScanResultLink]
);
workflowActionService.addCommentBox(mockCommentBox);

dispatchOnBody(new KeyboardEvent("keydown", { key: "a", metaKey: true }));

expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toContain(mockScanPredicate.operatorID);
expect(jointGraphWrapper.getCurrentHighlightedLinkIDs()).toContain(mockScanResultLink.linkID);
expect(jointGraphWrapper.getCurrentHighlightedCommentBoxIDs()).toContain(mockCommentBox.commentBoxID);
});
});

describe("disabled-operator stream", () => {
it("repaints an operator when it is disabled and again when it is re-enabled", () => {
workflowActionService.addOperator(mockScanPredicate, mockPoint);
const changeSpy = vi.spyOn(jointUIService, "changeOperatorDisableStatus");
try {
workflowActionService.disableOperators([mockScanPredicate.operatorID]);
expect(changeSpy).toHaveBeenCalledTimes(1);

workflowActionService.enableOperators([mockScanPredicate.operatorID]);
expect(changeSpy).toHaveBeenCalledTimes(2);
} finally {
changeSpy.mockRestore();
}
});
});

describe("clipboard", () => {
let operatorMenu: OperatorMenuService;

beforeEach(() => {
operatorMenu = TestBed.inject(OperatorMenuService);
workflowActionService.addOperator(mockScanPredicate, mockPoint);
// the copy/cut handlers read the menu's latest highlighted-element snapshot
workflowActionService.getJointGraphWrapper().highlightOperators(mockScanPredicate.operatorID);
});

it("caches the highlighted elements on copy", () => {
const saveSpy = vi.spyOn(operatorMenu, "saveHighlightedElements").mockImplementation(() => {});
try {
dispatchOnBody(new Event("copy"));
expect(saveSpy).toHaveBeenCalledTimes(1);
expect(workflowActionService.getTexeraGraph().hasOperator(mockScanPredicate.operatorID)).toBe(true);
} finally {
saveSpy.mockRestore();
}
});

it("caches and then deletes the highlighted elements on cut", () => {
const saveSpy = vi.spyOn(operatorMenu, "saveHighlightedElements").mockImplementation(() => {});
try {
dispatchOnBody(new Event("cut"));
expect(saveSpy).toHaveBeenCalledTimes(1);
expect(workflowActionService.getTexeraGraph().hasOperator(mockScanPredicate.operatorID)).toBe(false);
} finally {
saveSpy.mockRestore();
}
});

it("pastes the cached elements on paste", () => {
const pasteSpy = vi.spyOn(operatorMenu, "performPasteOperation").mockImplementation(() => {});
try {
dispatchOnBody(new Event("paste"));
expect(pasteSpy).toHaveBeenCalledTimes(1);
} finally {
pasteSpy.mockRestore();
}
});
});
});
});
});
Loading