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 45b7b20e05 test(frontend): add unit test coverage for 
ContextMenuComponent actions (#7148)
45b7b20e05 is described below

commit 45b7b20e05864bca98def642e329d501e664413e
Author: Eugene Gu <[email protected]>
AuthorDate: Thu Jul 30 20:57:45 2026 -0700

    test(frontend): add unit test coverage for ContextMenuComponent actions 
(#7148)
    
    ### What changes were proposed in this PR?
    
    `ContextMenuComponent` backs the canvas right-click menu, but its
    existing 13 tests only cover `canExecuteOperator` and its helpers; none
    of the action methods was invoked by any test.
    
    This PR extends `context-menu.component.spec.ts` with 16 tests covering
    the remaining public surface:
    
    - `onCopy` / `onPaste`: delegate to `OperatorMenuService`, each also
    asserting the sibling method is not called.
    - `onCut`: copy happens before delete (asserted via mock invocation
    order).
    - `onDelete` (mocked): all three deletion kinds are issued with the
    snapshotted id lists inside exactly one `bundleActions` call; nothing is
    deleted when the `bundleActions` callback is not run; the
    `hasLinkWithID` guard skips links no longer in the graph; highlighted
    ids are snapshotted before deletion mutates the live highlight arrays.
    - `onDelete` (real `WorkflowActionService` on a seeded graph): deleting
    an operator together with its attached highlighted link does not
    double-delete the link (the real `deleteLinkWithID` throws on a missing
    link); a standalone highlighted link is deleted while its endpoint
    operators survive; highlighted comment boxes are deleted; the whole
    mixed deletion is a single undo stack entry that one undo fully
    restores.
    - `hasHighlightedLinks`: false/true per link highlight state.
    - `onClickExportHighlightedExecutionResult`: modal opens with
    `ResultExportationComponent`, the workflow name, and the `context-menu`
    source marker.
    - Constructor subscriptions: `highlightedOperatorIds` /
    `highlightedCommentBoxIds` follow the service stream emissions.
    
    No production code is changed. The only shared-stub change is the
    `OperatorMenuService` stub's two highlight streams becoming
    `BehaviorSubject`s instead of `of([])` so the subscription tests can
    push emissions; both emit `[]` on subscribe, so the pre-existing tests
    are unaffected.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7147
    
    ### How was this PR tested?
    
    This PR only adds tests. From `frontend/`, `npx ng test --watch=false
    --include='**/context-menu.component.spec.ts'` passes 29/29 (13
    pre-existing + 16 new); prettier and eslint are clean. With coverage
    enabled, `context-menu.component.ts` reaches 100% statements, functions
    and branches.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Co-authored by: Claude Code (Claude Fable 5)
---
 .../context-menu/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 8bf8a0516f..27d740e7df 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<ContextMenuComponent>;
@@ -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
@@ -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,
@@ -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);
+  });
 });

Reply via email to