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

Reply via email to