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 @@ -27,7 +27,7 @@ import { WorkflowActionService } from "src/app/workspace/service/workflow-graph/
import { WorkflowResultService } from "src/app/workspace/service/workflow-result/workflow-result.service";
import { WorkflowResultExportService } from "src/app/workspace/service/workflow-result-export/workflow-result-export.service";
import { OperatorMenuService } from "src/app/workspace/service/operator-menu/operator-menu.service";
import { of } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { ReactiveFormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { NzDropDownModule } from "ng-zorro-antd/dropdown";
Expand All @@ -37,6 +37,15 @@ import { commonTestProviders } from "../../../../../common/testing/test-utils";
import type { Mocked } from "vitest";
import { JointGraphWrapper } from "src/app/workspace/service/workflow-graph/model/joint-graph-wrapper";
import { WorkflowGraph } from "src/app/workspace/service/workflow-graph/model/workflow-graph";
import { ResultExportationComponent } from "../../../result-exportation/result-exportation.component";
import { UndoRedoService } from "src/app/workspace/service/undo-redo/undo-redo.service";
import {
mockCommentBox,
mockPoint,
mockScanPredicate,
mockScanSentimentLink,
mockSentimentPredicate,
} from "src/app/workspace/service/workflow-graph/model/mock-workflow-data";
describe("ContextMenuComponent", () => {
let component: ContextMenuComponent;
let fixture: ComponentFixture<ContextMenuComponent>;
Expand All @@ -46,6 +55,8 @@ describe("ContextMenuComponent", () => {
let operatorMenuService: Mocked<OperatorMenuService>;
let jointGraphWrapperSpy: Mocked<JointGraphWrapper>;
let validationWorkflowService: Mocked<ValidationWorkflowService>;
let highlightedOperatorsSubject: BehaviorSubject<readonly string[]>;
let highlightedCommentBoxesSubject: BehaviorSubject<readonly string[]>;

beforeEach(async () => {
// Create spies for the services
Expand Down Expand Up @@ -85,10 +96,14 @@ describe("ContextMenuComponent", () => {
const workflowResultServiceSpy = { getResultService: vi.fn(), hasAnyResult: vi.fn() };
const workflowResultExportServiceSpy = { exportOperatorsResultAsFile: vi.fn() };

// Create a mock for OperatorMenuService with necessary properties and methods
// Create a mock for OperatorMenuService with necessary properties and methods.
// The highlight streams are BehaviorSubjects so tests can push new emissions
// and assert the component's constructor subscriptions stay in sync.
highlightedOperatorsSubject = new BehaviorSubject<readonly string[]>([]);
highlightedCommentBoxesSubject = new BehaviorSubject<readonly string[]>([]);
operatorMenuService = {
highlightedOperators$: of([] as readonly string[]),
highlightedCommentBoxes$: of([] as readonly string[]),
highlightedOperators$: highlightedOperatorsSubject.asObservable(),
highlightedCommentBoxes$: highlightedCommentBoxesSubject.asObservable(),
isDisableOperator: false,
isDisableOperatorClickable: false,
isToViewResult: false,
Expand Down Expand Up @@ -259,4 +274,310 @@ describe("ContextMenuComponent", () => {
expect(texeraGraphSpy.isOperatorDisabled).toHaveBeenCalledWith("op1");
});
});

describe("onCopy / onPaste", () => {
it("should delegate onCopy to OperatorMenuService.saveHighlightedElements", () => {
component.onCopy();

expect(operatorMenuService.saveHighlightedElements).toHaveBeenCalledTimes(1);
expect(operatorMenuService.performPasteOperation).not.toHaveBeenCalled();
});

it("should delegate onPaste to OperatorMenuService.performPasteOperation", () => {
component.onPaste();

expect(operatorMenuService.performPasteOperation).toHaveBeenCalledTimes(1);
expect(operatorMenuService.saveHighlightedElements).not.toHaveBeenCalled();
});
});

describe("onCut", () => {
it("should copy the highlighted elements before deleting them", () => {
jointGraphWrapperSpy.getCurrentHighlightedOperatorIDs.mockReturnValue(["op1"]);

component.onCut();

expect(operatorMenuService.saveHighlightedElements).toHaveBeenCalledTimes(1);
expect(workflowActionService.deleteOperatorsAndLinks).toHaveBeenCalledWith(["op1"]);
// cut is defined as copy followed by delete, so the ordering is behavior
expect(operatorMenuService.saveHighlightedElements.mock.invocationCallOrder[0]).toBeLessThan(
workflowActionService.deleteOperatorsAndLinks.mock.invocationCallOrder[0]
);
});
});

describe("hasHighlightedLinks", () => {
it("should return false when no links are highlighted", () => {
jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue([]);

expect(component.hasHighlightedLinks()).toBe(false);
});

it("should return true when at least one link is highlighted", () => {
jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue(["link-1"]);

expect(component.hasHighlightedLinks()).toBe(true);
});
});

describe("onDelete", () => {
let texeraGraphSpy: Mocked<WorkflowGraph>;

beforeEach(() => {
texeraGraphSpy = workflowActionService.getTexeraGraph() as unknown as Mocked<WorkflowGraph>;
});

it("should delete highlighted operators, standalone links, and comment boxes in one bundled action", () => {
jointGraphWrapperSpy.getCurrentHighlightedOperatorIDs.mockReturnValue(["op1", "op2"]);
jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue(["link-9"]);
jointGraphWrapperSpy.getCurrentHighlightedCommentBoxIDs.mockReturnValue(["box-1"]);
texeraGraphSpy.hasLinkWithID.mockReturnValue(true);

component.onDelete();

expect(texeraGraphSpy.bundleActions).toHaveBeenCalledTimes(1);
expect(workflowActionService.deleteOperatorsAndLinks).toHaveBeenCalledWith(["op1", "op2"]);
expect(workflowActionService.deleteLinkWithID).toHaveBeenCalledWith("link-9");
expect(workflowActionService.deleteCommentBox).toHaveBeenCalledWith("box-1");
});

it("should perform every deletion inside the bundleActions callback", () => {
jointGraphWrapperSpy.getCurrentHighlightedOperatorIDs.mockReturnValue(["op1"]);
jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue(["link-9"]);
jointGraphWrapperSpy.getCurrentHighlightedCommentBoxIDs.mockReturnValue(["box-1"]);
texeraGraphSpy.hasLinkWithID.mockReturnValue(true);
// if bundleActions never runs the callback, nothing at all may be deleted
texeraGraphSpy.bundleActions.mockImplementation(() => {});

component.onDelete();

expect(workflowActionService.deleteOperatorsAndLinks).not.toHaveBeenCalled();
expect(workflowActionService.deleteLinkWithID).not.toHaveBeenCalled();
expect(workflowActionService.deleteCommentBox).not.toHaveBeenCalled();
});

it("should skip highlighted links that no longer exist in the graph", () => {
jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue(["link-9"]);
texeraGraphSpy.hasLinkWithID.mockReturnValue(false);

component.onDelete();

expect(texeraGraphSpy.hasLinkWithID).toHaveBeenCalledWith("link-9");
expect(workflowActionService.deleteLinkWithID).not.toHaveBeenCalled();
});

it("should snapshot the highlighted IDs before deletion mutates the highlight state", () => {
const liveOperatorIDs = ["op1"];
const liveLinkIDs = ["link-9"];
const liveCommentBoxIDs = ["box-1"];
jointGraphWrapperSpy.getCurrentHighlightedOperatorIDs.mockReturnValue(liveOperatorIDs);
jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue(liveLinkIDs);
jointGraphWrapperSpy.getCurrentHighlightedCommentBoxIDs.mockReturnValue(liveCommentBoxIDs);
texeraGraphSpy.hasLinkWithID.mockReturnValue(true);
// deleting operators unhighlights every element, as the real graph would
workflowActionService.deleteOperatorsAndLinks.mockImplementation(() => {
liveOperatorIDs.length = 0;
liveLinkIDs.length = 0;
liveCommentBoxIDs.length = 0;
});

component.onDelete();

expect(workflowActionService.deleteLinkWithID).toHaveBeenCalledWith("link-9");
expect(workflowActionService.deleteCommentBox).toHaveBeenCalledWith("box-1");
});
});

describe("onClickExportHighlightedExecutionResult", () => {
it("should open the result exportation modal with the workflow name and context-menu source", () => {
const modalService = TestBed.inject(NzModalService);
const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as any);

component.onClickExportHighlightedExecutionResult();

expect(createSpy).toHaveBeenCalledTimes(1);
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
nzTitle: "Export Highlighted Operators Result",
nzContent: ResultExportationComponent,
nzData: {
workflowName: "Test Workflow",
sourceTriggered: "context-menu",
},
nzFooter: null,
})
);
});
});

describe("highlight subscriptions", () => {
it("should keep highlightedOperatorIds in sync with OperatorMenuService emissions", () => {
expect(component.highlightedOperatorIds).toEqual([]);

highlightedOperatorsSubject.next(["op-a", "op-b"]);
expect(component.highlightedOperatorIds).toEqual(["op-a", "op-b"]);

highlightedOperatorsSubject.next([]);
expect(component.highlightedOperatorIds).toEqual([]);
});

it("should keep highlightedCommentBoxIds in sync with OperatorMenuService emissions", () => {
expect(component.highlightedCommentBoxIds).toEqual([]);

highlightedCommentBoxesSubject.next(["box-a"]);
expect(component.highlightedCommentBoxIds).toEqual(["box-a"]);

highlightedCommentBoxesSubject.next([]);
expect(component.highlightedCommentBoxIds).toEqual([]);
});
});
});

describe("ContextMenuComponent onDelete with real WorkflowActionService", () => {
let component: ContextMenuComponent;
let fixture: ComponentFixture<ContextMenuComponent>;
let workflowActionService: WorkflowActionService;
let undoRedoService: UndoRedoService;

// mockCommentBox reuses operator ID "1" (operators and comment boxes share the
// joint-graph cell namespace), so give the comment box a distinct ID.
const commentBox = { ...mockCommentBox, commentBoxID: "comment-box-1" };

beforeEach(async () => {
// Only the collaborators onDelete does not touch are stubbed; deletions run
// through the real WorkflowActionService against a seeded graph.
const operatorMenuServiceStub = {
highlightedOperators$: of([] as readonly string[]),
highlightedCommentBoxes$: of([] as readonly string[]),
isDisableOperator: false,
isDisableOperatorClickable: false,
isToViewResult: false,
isToViewResultClickable: false,
isMarkForReuse: false,
isReuseResultClickable: false,
saveHighlightedElements: vi.fn(),
performPasteOperation: vi.fn(),
} as unknown as OperatorMenuService;

await TestBed.configureTestingModule({
providers: [
{ provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
{ provide: WorkflowResultService, useValue: { getResultService: vi.fn(), hasAnyResult: vi.fn() } },
{ provide: WorkflowResultExportService, useValue: { exportOperatorsResultAsFile: vi.fn() } },
{ provide: OperatorMenuService, useValue: operatorMenuServiceStub },
{ provide: ValidationWorkflowService, useValue: { validateOperator: vi.fn() } },
NzModalService,
...commonTestProviders,
],
imports: [
ContextMenuComponent,
HttpClientTestingModule,
ReactiveFormsModule,
BrowserAnimationsModule,
NzDropDownModule,
NzModalModule,
],
}).compileComponents();

workflowActionService = TestBed.inject(WorkflowActionService);
undoRedoService = TestBed.inject(UndoRedoService);

fixture = TestBed.createComponent(ContextMenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

/**
* Seeds scan -> sentiment connected by mockScanSentimentLink, then clears the
* auto-highlight state and enables multi-select so tests can highlight exactly
* the elements they want deleted.
*/
function seedScanSentimentGraph(): void {
workflowActionService.addOperatorsAndLinks(
[
{ op: mockScanPredicate, pos: mockPoint },
{ op: mockSentimentPredicate, pos: mockPoint },
],
[mockScanSentimentLink]
);
const wrapper = workflowActionService.getJointGraphWrapper();
wrapper.unhighlightOperators(...wrapper.getCurrentHighlightedOperatorIDs());
wrapper.unhighlightLinks(...wrapper.getCurrentHighlightedLinkIDs());
wrapper.setMultiSelectMode(true);
}

it("should delete a highlighted operator with its attached link without double-deleting the link", () => {
seedScanSentimentGraph();
const wrapper = workflowActionService.getJointGraphWrapper();
const texeraGraph = workflowActionService.getTexeraGraph();
// highlight both the operator and its attached link: deleting the operator
// removes the link first, so the standalone-link pass must hit the
// hasLinkWithID guard instead of deleting (and throwing on) a missing link.
wrapper.highlightOperators(mockScanPredicate.operatorID);
wrapper.highlightLinks(mockScanSentimentLink.linkID);
expect(wrapper.getCurrentHighlightedLinkIDs()).toEqual([mockScanSentimentLink.linkID]);

component.onDelete();

expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBe(false);
expect(texeraGraph.hasLinkWithID(mockScanSentimentLink.linkID)).toBe(false);
expect(texeraGraph.hasOperator(mockSentimentPredicate.operatorID)).toBe(true);
});

it("should delete a standalone highlighted link and keep its endpoint operators", () => {
seedScanSentimentGraph();
const wrapper = workflowActionService.getJointGraphWrapper();
const texeraGraph = workflowActionService.getTexeraGraph();
wrapper.highlightLinks(mockScanSentimentLink.linkID);

component.onDelete();

expect(texeraGraph.hasLinkWithID(mockScanSentimentLink.linkID)).toBe(false);
expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBe(true);
expect(texeraGraph.hasOperator(mockSentimentPredicate.operatorID)).toBe(true);
});

it("should delete highlighted comment boxes", () => {
workflowActionService.addCommentBox(commentBox);
const wrapper = workflowActionService.getJointGraphWrapper();
const texeraGraph = workflowActionService.getTexeraGraph();
wrapper.highlightCommentBoxes(commentBox.commentBoxID);

component.onDelete();

expect(texeraGraph.hasCommentBox(commentBox.commentBoxID)).toBe(false);
});

it("should bundle the whole deletion into a single undo step", () => {
seedScanSentimentGraph();
workflowActionService.addCommentBox(commentBox);
const wrapper = workflowActionService.getJointGraphWrapper();
const texeraGraph = workflowActionService.getTexeraGraph();
// addCommentBox turns multi-select off again, so re-enable it before
// highlighting the whole graph.
wrapper.setMultiSelectMode(true);
wrapper.highlightOperators(mockScanPredicate.operatorID, mockSentimentPredicate.operatorID);
wrapper.highlightLinks(mockScanSentimentLink.linkID);
wrapper.highlightCommentBoxes(commentBox.commentBoxID);

// the yjs undo manager merges transactions within its capture timeout, so
// force the deletion to start a fresh undo stack item.
(texeraGraph as WorkflowGraph).sharedModel.undoManager.stopCapturing();
const undoLengthBefore = undoRedoService.getUndoLength();

component.onDelete();

expect(texeraGraph.getAllOperators()).toEqual([]);
expect(texeraGraph.getAllLinks()).toEqual([]);
expect(texeraGraph.getAllCommentBoxes()).toEqual([]);
expect(undoRedoService.getUndoLength()).toBe(undoLengthBefore + 1);

// a single undo must restore every deleted element at once
undoRedoService.undoAction();

expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBe(true);
expect(texeraGraph.hasOperator(mockSentimentPredicate.operatorID)).toBe(true);
expect(texeraGraph.hasLinkWithID(mockScanSentimentLink.linkID)).toBe(true);
expect(texeraGraph.hasCommentBox(commentBox.commentBoxID)).toBe(true);
});
});
Loading