This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7354-bba2951adb3c089a22c7854cbb8dfc4230500eeb in repository https://gitbox.apache.org/repos/asf/texera.git
commit 99b07dba17239d3bf059730b070f8fe21ae39d15 Author: Xinyuan Lin <[email protected]> AuthorDate: Thu Aug 6 20:15:49 2026 -0700 test(frontend): cover the operator menu's list construction and selection (#7354) ### What changes were proposed in this PR? `OperatorMenuComponent` had **4 tests across 153 lines**, all of them about the search box. Two whole areas were untested. **The constructor's metadata subscription** — this is what actually populates the palette: ``` metadata -> filter out PythonUDF / Dummy -> bucket by operatorGroupName -> sort each bucket -> fuse.setCollection(ops) ``` A regression here silently drops operators from the palette or reshuffles them, with nothing failing. Four tests cover the bucketing, the per-group sort, and that `groupNames` comes from the metadata rather than being derived from the operators present — deriving it would silently drop a heading whenever a group happens to be empty. The PythonUDF test asserts absence from **both** the palette and the search index, because the filter feeds both `opList` and `fuse.setCollection`. Asserting only the palette would miss a filter applied in one place only. **`onSelectionChange` was untested entirely.** It places the new operator relative to the canvas pan offset, so the arithmetic is asserted against a paper translated by (100, 25) — the operator must land at **(300, 175)**, not the raw (400, 200). The no-paper fallback is covered too, since the `?? 0` guards exist for the window before the editor mounts. A third test pins that the search box clears **asynchronously**, and deliberately asserts the value is *still set* immediately after the call. That is not an accident of the implementation: ng-zorro re-displays the selected value if it is cleared synchronously, which is why the `setTimeout` is there. A test that only checked the end state would pass with the `setTimeout` removed. Also covers `canModify` tracking the workflow-modification stream — the palette disables drag-and-drop on that flag, so a stuck value lets a user drag operators onto a read-only workflow. **Assertion strength measured by mutation**, all reverted (component diff empty): | Mutation | Result | |---|---| | replace the per-group sort with `reverse()` | red | | stop filtering `PythonUDF` | red | | ignore the pan offset | red | | clear the search box synchronously | red | Not covered, and worth stating: the `Sleep` handling is asymmetric — it is excluded from `opList` but left in the fuse collection, so it is searchable without being listed. The standard `StubOperatorMetadataService` fixture contains no `Sleep` operator, so pinning that would need a custom metadata provider; noted here rather than half-tested. No production file is touched. ### Any related issues, documentation, discussions? Closes #7352 ### How was this PR tested? ``` npx ng test --watch=false --include="**/operator-menu.component.spec.ts" ``` ``` ✓ src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts (12 tests) Test Files 1 passed (1) ``` 8 new tests on top of the existing 4. `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --------- Signed-off-by: Xinyuan Lin <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../operator-menu/operator-menu.component.spec.ts | 124 ++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts index 4ef4b5ab2a..a9e133827f 100644 --- a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts +++ b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts @@ -17,10 +17,13 @@ * under the License. */ -import { mockScanSourceSchema } from "../../../service/operator-metadata/mock-operator-metadata.data"; +import { + mockOperatorGroup, + mockScanSourceSchema, +} from "../../../service/operator-metadata/mock-operator-metadata.data"; import { UndoRedoService } from "../../../service/undo-redo/undo-redo.service"; import { DragDropService } from "../../../service/drag-drop/drag-drop.service"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { OperatorMenuComponent } from "./operator-menu.component"; import { OperatorLabelComponent } from "./operator-label/operator-label.component"; @@ -32,6 +35,8 @@ import { JointUIService } from "../../../service/joint-ui/joint-ui.service"; import { WorkflowUtilService } from "../../../service/workflow-graph/util/workflow-util.service"; import { NzDropDownModule } from "ng-zorro-antd/dropdown"; import { NzCollapseModule } from "ng-zorro-antd/collapse"; +import type { NzAutocompleteOptionComponent } from "ng-zorro-antd/auto-complete"; +import type * as joint from "jointjs"; import { commonTestProviders } from "../../../../common/testing/test-utils"; describe("OperatorPanelComponent", () => { @@ -89,6 +94,121 @@ describe("OperatorPanelComponent", () => { expect(component.autocompleteOptions[0]).toBe(mockScanSourceSchema); }); + /** + * The constructor's metadata subscription is what actually populates the panel, and none of it + * was covered. It filters operator types out, buckets the rest by group, and sorts each bucket - + * so a regression here silently drops operators from the palette or scrambles their order, with + * no error anywhere. + */ + describe("operator list construction", () => { + it("buckets operators by their group name", () => { + // Every listed operator must land under its own declared group; a grouping bug would show up + // as an operator filed under the wrong key rather than as a crash. + component.opList.forEach((operators, group) => { + operators.forEach(op => expect(op.additionalMetadata.operatorGroupName).toBe(group)); + }); + expect(component.opList.get("Source")).toBeDefined(); + expect(component.opList.get("Analysis")).toBeDefined(); + }); + + it("sorts each group by operatorType", () => { + // The palette renders in Map order, so an unsorted (or differently sorted) group is a + // visible reshuffle for users. + component.opList.forEach(operators => { + const types = operators.map(op => op.operatorType); + expect(types).toEqual([...types].sort((a, b) => a.localeCompare(b))); + }); + }); + + it("excludes PythonUDF from both the palette and the search index", () => { + // PythonUDF is filtered before the list is built AND before fuse.setCollection, so it must be + // absent from both. Asserting only the palette would miss a filter applied in one place only. + const listed = [...component.opList.values()].flat().map(op => op.operatorType); + expect(listed).not.toContain("PythonUDF"); + + component.onInput({ target: { value: "Python UDF" } } as unknown as Event); + expect(component.autocompleteOptions.map(op => op.operatorType)).not.toContain("PythonUDF"); + }); + + it("takes its group headings from the metadata, not from the operators present", () => { + // groupNames drives the collapse panels; deriving it from opList instead would silently drop + // a heading whenever a group happens to contain no operators. + expect(component.groupNames).toEqual(mockOperatorGroup); + }); + }); + + describe("workflow modification state", () => { + it("tracks whether the workflow may be modified", () => { + const workflowActionService = TestBed.inject(WorkflowActionService); + expect(component.canModify).toBe(true); + + // The palette disables drag-and-drop on this flag, so a stuck value lets a user drag + // operators onto a read-only workflow. + workflowActionService.disableWorkflowModification(); + expect(component.canModify).toBe(false); + + workflowActionService.enableWorkflowModification(); + expect(component.canModify).toBe(true); + }); + }); + + describe("selecting a search result", () => { + it("places the operator relative to the current pan offset", fakeAsync(() => { + const workflowActionService = TestBed.inject(WorkflowActionService); + // Pretend the canvas has been panned; the new operator must land at a fixed point in view + // space, which means subtracting the paper's translation rather than using raw coordinates. + vi.spyOn(workflowActionService.getJointGraphWrapper(), "getMainJointPaper").mockReturnValue({ + translate: () => ({ tx: 100, ty: 25 }), + } as unknown as joint.dia.Paper); + const addOperator = vi.spyOn(workflowActionService, "addOperator"); + + component.onSelectionChange({ + nzValue: mockScanSourceSchema, + } as unknown as NzAutocompleteOptionComponent); + + expect(addOperator).toHaveBeenCalledTimes(1); + expect(addOperator.mock.calls[0][1]).toEqual({ x: 300, y: 175 }); + tick(0); + })); + + it("falls back to the untranslated point when no paper is attached", fakeAsync(() => { + const workflowActionService = TestBed.inject(WorkflowActionService); + vi.spyOn(workflowActionService.getJointGraphWrapper(), "getMainJointPaper").mockReturnValue( + undefined as unknown as joint.dia.Paper + ); + const addOperator = vi.spyOn(workflowActionService, "addOperator"); + + component.onSelectionChange({ + nzValue: mockScanSourceSchema, + } as unknown as NzAutocompleteOptionComponent); + + // The ?? 0 guards exist because the paper is absent until the editor mounts. + expect(addOperator.mock.calls[0][1]).toEqual({ x: 400, y: 200 }); + tick(0); + })); + + it("clears the search box asynchronously after the selection", fakeAsync(() => { + const workflowActionService = TestBed.inject(WorkflowActionService); + vi.spyOn(workflowActionService.getJointGraphWrapper(), "getMainJointPaper").mockReturnValue( + undefined as unknown as joint.dia.Paper + ); + component.searchInputValue = "scan"; + component.onInput({ target: { value: "scan" } } as unknown as Event); + + component.onSelectionChange({ + nzValue: mockScanSourceSchema, + } as unknown as NzAutocompleteOptionComponent); + + // Deliberately still set right after the call: the clear is deferred through setTimeout + // because ng-zorro re-displays the selected value if it is cleared synchronously. + expect(component.searchInputValue).toBe("scan"); + + tick(0); + expect(component.searchInputValue).toBe(""); + expect(component.autocompleteOptions).toEqual([]); + })); + }); + it("should clear the search box when an operator from search box is dropped", () => { component.searchInputValue = "scan"; component.onInput({ target: { value: "scan" } } as unknown as Event);
