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);
+ });
});