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 99b07dba17 test(frontend): cover the operator menu's list construction
and selection (#7354)
99b07dba17 is described below
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);