From 5cf88217e9482a284c8c515931af83e47808a889 Mon Sep 17 00:00:00 2001 From: eugenegujing Date: Thu, 30 Jul 2026 16:50:53 -0700 Subject: [PATCH] test(frontend): add unit test coverage for ContextMenuComponent actions Extends `context-menu.component.spec.ts` with 16 tests for the previously untested action surface of `ContextMenuComponent`: `onCopy`/`onPaste` delegation to `OperatorMenuService`, `onCut` copy-before-delete ordering, `onDelete` (all deletions in one `bundleActions` call, the `hasLinkWithID` guard against double-deleting a link removed with its operator, id snapshot before mutation, plus four tests through the real `WorkflowActionService` on a seeded graph including single-undo-step restoration), `hasHighlightedLinks`, the `onClickExportHighlightedExecutionResult` modal parameters, and the two constructor highlight subscriptions. No production code is changed. --- .../context-menu.component.spec.ts | 329 +++++++++++++++++- 1 file changed, 325 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts index 8bf8a0516f5..27d740e7dfa 100644 --- a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts +++ b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts @@ -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"; @@ -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; @@ -46,6 +55,8 @@ describe("ContextMenuComponent", () => { let operatorMenuService: Mocked; let jointGraphWrapperSpy: Mocked; let validationWorkflowService: Mocked; + let highlightedOperatorsSubject: BehaviorSubject; + let highlightedCommentBoxesSubject: BehaviorSubject; beforeEach(async () => { // Create spies for the services @@ -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([]); + highlightedCommentBoxesSubject = new BehaviorSubject([]); operatorMenuService = { - highlightedOperators$: of([] as readonly string[]), - highlightedCommentBoxes$: of([] as readonly string[]), + highlightedOperators$: highlightedOperatorsSubject.asObservable(), + highlightedCommentBoxes$: highlightedCommentBoxesSubject.asObservable(), isDisableOperator: false, isDisableOperatorClickable: false, isToViewResult: false, @@ -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; + + beforeEach(() => { + texeraGraphSpy = workflowActionService.getTexeraGraph() as unknown as Mocked; + }); + + 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; + 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); + }); });