You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/08/22 10:25:44 UTC

(texera) 01/02: test(frontend): drive the workspace menu template through the DOM (#7840)

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

commit fb7f33bdebe35683893b570831f257ffbab94f3f
Author: Meng Wang <me...@uci.edu>
AuthorDate: Sat Aug 22 10:14:22 2026 +0000

    test(frontend): drive the workspace menu template through the DOM (#7840)
    
    ### What changes were proposed in this PR?
    
    The spec called the handlers directly and rendered almost nothing. These
    14 tests fire them
    from the markup instead, taking `menu.component.html` from 187/295
    statements to 264/295 and
    its branches from 32/58 to 42/58 — 74 uncovered lines down to 20.
    
    - **Version bar** — the whole `displayParticularWorkflowVersion` arm:
    the name input gives
    way to the version label, the back button closes the display, restore is
    offered only
    while the version service allows it (both states asserted) and clone is
    wired; the
    "Current Version: N" label appears only when a version id is selected;
    one icon renders
      per co-editor.
    - **Workflow name field** — the id badge appears only with a workflow
    id, and typing and
    committing the name reach `adjustWorkflowNameWidth` /
    `onWorkflowNameChange`.
    - **Toolbar** — create, save, delete-all, export and edit-description
    fired from their
      buttons.
    - **Operator actions** — each of disable / view-result / reuse-result
    renders as one of two
    buttons depending on the state it would move to; both are driven, plus
    the disabled state
    when nothing is selected. Undo and redo are wired and are disabled while
    an older version
      is displayed.
    - **Execution buttons** — share, kill and run; and the checkpoint
    button, which appears only
    with time travel enabled and is enabled only while the execution is
    paused.
    
    What made this reachable: the utilities block is rendered inline by the
    unconditional
    `ngTemplateOutlet` in `#expanded-utilities` (the responsive hiding is
    CSS, not `*ngIf`), so
    the operator actions and history buttons are in the DOM under jsdom
    without opening the
    dropdown that also renders them.
    
    Per the issue: no assertions on layout or on `DatePipe`-rendered text,
    `fixture.destroy()`
    and `vi.restoreAllMocks()` in the block's `afterEach`, and nothing
    asserted about
    project-specific menu entries.
    
    No production code was changed.
    
    ### The 20 lines left, and why
    
    Nineteen of them are content ng-zorro renders into a CDK overlay, which
    jsdom does not attach:
    
    - **170, 178, 186, 194** — the Layers checkboxes and their
    `(ngModelChange)` handlers. They
    live in `<nz-dropdown-menu #menu>`;
    `queryAll(By.css("label[nz-checkbox]"))` finds none
      anywhere in the fixture, so there is nothing to drive.
    - **419-430** — the `#executionSettings` popover content (execution name
    input, email
      notification switch), reached only through `[nzPopoverContent]`.
    - **442-448** — the `#clusterInfo` tooltip content
    (connected/disconnected text, computing
      unit status, worker count), reached only through `[nzTooltipTitle]`.
    
    And one that is unreachable for a different reason:
    
    - **306** — the `(click)` on the "reuse result if possible" button. Line
    305 hard-disables it
    with `[disabled]="true || ! operatorMenu.isReuseResultClickable"`, so
    the listener cannot be
    reached through the UI; the test asserts the disabled state rather than
    firing a handler the
    user cannot invoke. Worth a look separately: the button looks switched
    off by prefixing its
      guard rather than by removing it.
    
    I hit the same wall on #7655 and chose not to build timing machinery for
    it there either:
    the overlay appears only after an unbounded wait, so covering these
    would mean guessing a
    duration — the flake this repo's specs avoid.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7837.
    
    Rebased on current `main`. No assertions depend on the project menu
    entries #7463 removes.
    
    ### How was this PR tested?
    
    `ng test --watch=false --include
    src/app/workspace/component/menu/menu.component.spec.ts` —
    76 passed (62 existing + 14 new), run 3x for determinism. Coverage
    (`--coverage`) gives the
    numbers above. The failure path was verified by breaking an assertion
    (red, non-zero exit)
    and restoring it. `yarn --cwd frontend format:ci`, the repo's own lint
    step, is clean.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../component/menu/menu.component.spec.ts          | 248 +++++++++++++++++++++
 1 file changed, 248 insertions(+)

diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
index 106695d8af..51f1e6c26f 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
@@ -19,6 +19,7 @@
 
 import { DatePipe, Location } from "@angular/common";
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { RouterTestingModule } from "@angular/router/testing";
 import { NzModalService, NzModalModule, NzModalRef } from "ng-zorro-antd/modal";
@@ -968,4 +969,251 @@ describe("MenuComponent", () => {
       expect(reset).not.toHaveBeenCalled();
     });
   });
+
+  /**
+   * The suite above calls the handlers directly; these fire them from the rendered markup, so a
+   * control that loses its binding fails here. The utilities block is reached through
+   * `#expanded-utilities`, the outlet that renders it inline — the dropdown outlet next to it
+   * needs a CDK overlay, which jsdom never attaches.
+   */
+  describe("rendered menu", () => {
+    const q = (selector: string) => fixture.debugElement.query(By.css(selector));
+    const utility = (title: string) => q(`#expanded-utilities button[title="${title}"]`);
+    const toolbar = (title: string) => q(`button[title="${title}"]`);
+    /** Undo/redo and export carry no title; they are identified by the icon they render. */
+    const buttonWithIcon = (icon: string) => q(`#expanded-utilities i[nztype="${icon}"]`).parent!;
+
+    afterEach(() => {
+      fixture.destroy();
+      vi.restoreAllMocks();
+    });
+
+    describe("version display bar", () => {
+      /** Puts the menu into the "viewing an older version" state and renders it. */
+      function showVersion(versionId: number | null = 7): void {
+        component.displayParticularWorkflowVersion = true;
+        workflowVersionService.selectedDisplayedVersionId.next(versionId);
+        fixture.detectChanges();
+      }
+
+      it("swaps the name input for the version label and a way back", () => {
+        const closeSpy = vi.spyOn(component, "closeParticularVersionDisplay").mockImplementation(() => {});
+        showVersion();
+
+        expect(q("input.workflow-name")).toBeNull();
+        toolbar("back").triggerEventHandler("click", null);
+
+        expect(closeSpy).toHaveBeenCalled();
+      });
+
+      it("offers restore only while the version service allows it", () => {
+        const revertSpy = vi.spyOn(component, "revertToVersion").mockImplementation(() => {});
+        vi.spyOn(workflowVersionService, "canRestoreVersion", "get").mockReturnValue(false);
+        showVersion();
+
+        const restore = fixture.debugElement
+          .queryAll(By.css("button"))
+          .find(b => (b.nativeElement.textContent ?? "").trim() === "Restore this version")!;
+        expect(restore.nativeElement.disabled).toBe(true);
+
+        vi.spyOn(workflowVersionService, "canRestoreVersion", "get").mockReturnValue(true);
+        fixture.detectChanges();
+        expect(restore.nativeElement.disabled).toBe(false);
+
+        restore.triggerEventHandler("click", null);
+        expect(revertSpy).toHaveBeenCalled();
+      });
+
+      it("offers cloning the displayed version", () => {
+        const cloneSpy = vi.spyOn(component, "cloneVersion").mockImplementation(() => {});
+        showVersion();
+
+        fixture.debugElement
+          .queryAll(By.css("button"))
+          .find(b => (b.nativeElement.textContent ?? "").trim() === "Clone this version")!
+          .triggerEventHandler("click", null);
+
+        expect(cloneSpy).toHaveBeenCalled();
+      });
+
+      it("names the displayed version, and says nothing when there is none", () => {
+        showVersion(7);
+        expect(q('[title="Current Version"]').nativeElement.textContent).toContain("7");
+
+        showVersion(null);
+        expect(q('[title="Current Version"]')).toBeNull();
+      });
+
+      it("renders an icon per co-editor", () => {
+        component.coeditorPresenceService.coeditors = [
+          { clientId: "a", user: { name: "ann" } },
+          { clientId: "b", user: { name: "bo" } },
+        ] as never;
+        fixture.detectChanges();
+
+        expect(fixture.debugElement.queryAll(By.css("texera-coeditor-user-icon")).length).toBe(2);
+      });
+    });
+
+    describe("workflow name field", () => {
+      it("shows the id badge and reports typing and committing the name", () => {
+        const widthSpy = vi.spyOn(component, "adjustWorkflowNameWidth").mockImplementation(() => {});
+        const changeSpy = vi.spyOn(component, "onWorkflowNameChange").mockImplementation(() => {});
+        component.workflowId = 42;
+        fixture.detectChanges();
+
+        expect(q("#metadata nz-avatar")).not.toBeNull();
+        const nameInput = q("input.workflow-name");
+        nameInput.triggerEventHandler("input", { target: nameInput.nativeElement });
+        nameInput.triggerEventHandler("change", { target: nameInput.nativeElement });
+
+        expect(widthSpy).toHaveBeenCalled();
+        expect(changeSpy).toHaveBeenCalled();
+      });
+
+      it("drops the id badge when there is no workflow yet", () => {
+        component.workflowId = undefined;
+        fixture.detectChanges();
+
+        expect(q("#metadata nz-avatar")).toBeNull();
+      });
+    });
+
+    describe("execution buttons", () => {
+      it("wires share, kill and run", () => {
+        const share = vi.spyOn(component, "onClickOpenShareAccess").mockResolvedValue(undefined);
+        const kill = vi.spyOn(component, "handleKill").mockImplementation(() => {});
+        const run = vi.spyOn(component, "onClickRunHandler").mockImplementation(() => {});
+        fixture.detectChanges();
+
+        q("#share-button").triggerEventHandler("click", null);
+        q("button[nzdanger]").triggerEventHandler("click", null);
+        q("#run-button").triggerEventHandler("click", null);
+
+        expect(share).toHaveBeenCalled();
+        expect(run).toHaveBeenCalled();
+        expect(kill).toHaveBeenCalled();
+      });
+    });
+
+    describe("toolbar", () => {
+      it("wires each toolbar button to its handler", () => {
+        const spies = {
+          create: vi.spyOn(component, "onClickCreateNewWorkflow").mockImplementation(() => {}),
+          save: vi.spyOn(component, "persistWorkflow").mockImplementation(() => {}),
+          deleteAll: vi.spyOn(component, "onClickDeleteAllOperators").mockImplementation(() => {}),
+          exportWorkflow: vi.spyOn(component, "onClickExportWorkflow").mockImplementation(() => {}),
+          description: vi.spyOn(component, "onClickEditDescription").mockImplementation(() => {}),
+        };
+        fixture.detectChanges();
+
+        toolbar("create new").triggerEventHandler("click", null);
+        toolbar("save").triggerEventHandler("click", null);
+        toolbar("delete all").triggerEventHandler("click", null);
+        toolbar("export workflow").triggerEventHandler("click", null);
+        toolbar("change description").triggerEventHandler("click", null);
+
+        Object.values(spies).forEach(spy => expect(spy).toHaveBeenCalled());
+      });
+    });
+
+    describe("operator actions", () => {
+      it("keeps the operator actions disabled while nothing is selected", () => {
+        component.operatorMenu.isDisableOperatorClickable = false;
+        component.operatorMenu.isToViewResultClickable = false;
+        component.operatorMenu.isReuseResultClickable = false;
+        fixture.detectChanges();
+
+        expect(utility("disable operators").nativeElement.disabled).toBe(true);
+        expect(utility("view result").nativeElement.disabled).toBe(true);
+        expect(utility("reuse result if possible").nativeElement.disabled).toBe(true);
+      });
+
+      it("wires each operator action in both of its rendered states", () => {
+        const disable = vi.spyOn(component.operatorMenu, "disableHighlightedOperators").mockImplementation(() => {});
+        const view = vi.spyOn(component.operatorMenu, "viewResultHighlightedOperators").mockImplementation(() => {});
+        const reuse = vi.spyOn(component.operatorMenu, "reuseResultHighlightedOperator").mockImplementation(() => {});
+        const menu = component.operatorMenu;
+        menu.isDisableOperatorClickable = true;
+        menu.isToViewResultClickable = true;
+        menu.isReuseResultClickable = true;
+
+        // Each action renders as one of two buttons depending on the state it would move to.
+        menu.isDisableOperator = true;
+        menu.isToViewResult = true;
+        menu.isMarkForReuse = true;
+        fixture.detectChanges();
+        expect(utility("disable operators").nativeElement.disabled).toBe(false);
+        utility("disable operators").triggerEventHandler("click", null);
+        utility("view result").triggerEventHandler("click", null);
+        // The "reuse result if possible" button is hard-disabled in the template
+        // (`[disabled]="true || …"`), so it is asserted, not clicked — firing its handler
+        // would claim an interaction the UI cannot perform.
+        expect(utility("reuse result if possible").nativeElement.disabled).toBe(true);
+
+        menu.isDisableOperator = false;
+        menu.isToViewResult = false;
+        menu.isMarkForReuse = false;
+        fixture.detectChanges();
+        utility("operators disabled, click to re-enable").triggerEventHandler("click", null);
+        utility("click to remove view result").triggerEventHandler("click", null);
+        utility("remove reusing previous result").triggerEventHandler("click", null);
+
+        expect(disable).toHaveBeenCalledTimes(2);
+        expect(view).toHaveBeenCalledTimes(2);
+        expect(reuse).toHaveBeenCalledTimes(1);
+      });
+
+      it("wires the export-result button", () => {
+        const exportSpy = vi.spyOn(component, "onClickExportExecutionResult").mockImplementation(() => {});
+        component.isExportDeactivate = false;
+        fixture.detectChanges();
+
+        buttonWithIcon("cloud-download").triggerEventHandler("click", null);
+
+        expect(exportSpy).toHaveBeenCalled();
+      });
+
+      it("wires undo and redo, and disables them while an older version is displayed", () => {
+        const undo = vi.spyOn(component.undoRedoService, "undoAction").mockImplementation(() => {});
+        const redo = vi.spyOn(component.undoRedoService, "redoAction").mockImplementation(() => {});
+        vi.spyOn(component.undoRedoService, "canUndo").mockReturnValue(true);
+        vi.spyOn(component.undoRedoService, "canRedo").mockReturnValue(true);
+        fixture.detectChanges();
+
+        buttonWithIcon("undo").triggerEventHandler("click", null);
+        buttonWithIcon("redo").triggerEventHandler("click", null);
+        expect(undo).toHaveBeenCalled();
+        expect(redo).toHaveBeenCalled();
+
+        // Viewing an older version makes the graph read-only, so history is off limits.
+        component.displayParticularWorkflowVersion = true;
+        fixture.detectChanges();
+        expect(buttonWithIcon("undo").nativeElement.disabled).toBe(true);
+        expect(buttonWithIcon("redo").nativeElement.disabled).toBe(true);
+      });
+    });
+
+    describe("checkpoint button", () => {
+      it("appears only with time travel on, and is enabled only while paused", () => {
+        const checkpoint = vi.spyOn(component, "handleCheckpoint").mockImplementation(() => {});
+        const guiConfig = TestBed.inject(GuiConfigService);
+        guiConfig.env.timetravelEnabled = false;
+        fixture.detectChanges();
+        expect(q("#checkpoint-button")).toBeNull();
+
+        guiConfig.env.timetravelEnabled = true;
+        component.executionState = ExecutionState.Running;
+        fixture.detectChanges();
+        expect(q("#checkpoint-button").nativeElement.disabled).toBe(true);
+
+        component.executionState = ExecutionState.Paused;
+        fixture.detectChanges();
+        expect(q("#checkpoint-button").nativeElement.disabled).toBe(false);
+
+        q("#checkpoint-button").triggerEventHandler("click", null);
+        expect(checkpoint).toHaveBeenCalled();
+      });
+    });
+  });
 });