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 f9deb66dcccfb3b5994be659350021c2e0aa8e06
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sun Aug 9 02:18:12 2026 -0700

    test(frontend): render the workflow list item's editing gates and row 
actions (#7424)
    
    ### What changes were proposed in this PR?
    
    The existing suite calls the component's methods directly, so the
    template's own decisions were never rendered: which control a click
    reaches, what value an edit forwards, and which actions a row offers.
    
    Adds 13 tests. The ones that carry real weight:
    
    - **Renaming forwards the text that was typed.** The input is seeded
    with the current name, so binding `workflow.name` instead would look
    right on screen while silently discarding every rename.
    - **The inline description gate is `editingDescription = editable`.**
    Without it a shared read-only row opens an editor whose save the backend
    then rejects.
    - **The shared-access tooltip composes `accessLevel` then `ownerName`,**
    and is shown only to a non-owner.
    - **The per-tag remove passes the `ngFor` loop variable,** not the
    component's `pid`.
    - **Duplicate and delete stay on their own outputs,** and delete is
    disabled for a non-owner.
    - **The executions action appears only when execution tracking is
    configured on.**
    
    **Verified by mutation**, all reverted (template diff empty):
    
    | Mutation | Result |
    |---|---|
    | rename forwards the old name instead of the typed one | red |
    | drop the read-only gate on inline description editing | red |
    | swap the two interpolations in the shared-access tooltip | red |
    | show the shared-access marker to the owner too | red |
    | pass the component's `pid` to the per-tag remove | red |
    | stop disabling delete for a non-owner | red |
    | always show the executions action | red |
    | make duplicate emit `deleted` | red |
    | invert the avatar indent | red |
    | invert the light/dark tag arms | red |
    | invert the tag text colour | red |
    
    The light/dark test **survived its first mutation**: inverting both arms
    merely swaps which tag gets which class, so an assertion that "both
    classes appear somewhere" cannot see it. It now checks each tag
    individually, and both that mutation and the text-colour one fail.
    
    Three things worth recording, all commented in the spec:
    
    - ng-zorro consumes the `nz-tooltip` attribute, and an interpolated
    title is a property binding that never reaches the DOM at all. Elements
    are located through `NzTooltipDirective` and its `directiveTitle`; an
    attribute selector finds nothing.
    - `StubWorkflowPersistService` does not declare `updateWorkflowName`, so
    it cannot be spied on — the suite supplies its own persist stub.
    - The project colours are supplied locally. The shared
    `testUserProjects` fixture stores colours that already carry a `'#'`
    while the template prepends one, so every tag fails the format check and
    takes the dark arm, leaving the light arm unreachable.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7421
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false 
--include="**/user-workflow-list-item.component.spec.ts"
    ```
    
    ```
     Test Files  1 passed (1)
          Tests  28 passed (28)
    ```
    
    13 new on top of the existing 15. `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]>
---
 .../user-workflow-list-item.component.spec.ts      | 245 ++++++++++++++++++++-
 1 file changed, 244 insertions(+), 1 deletion(-)

diff --git 
a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts
index 4174dc05d4..0fee1f9cf4 100644
--- 
a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts
@@ -39,9 +39,11 @@ import { NzModalModule, NzModalService } from 
"ng-zorro-antd/modal";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { provideRouter } from "@angular/router";
 import { DashboardEntry } from "../../../../type/dashboard-entry";
-import { NzTooltipModule } from "ng-zorro-antd/tooltip";
+import { NzTooltipDirective, NzTooltipModule } from "ng-zorro-antd/tooltip";
 import { commonTestProviders } from "../../../../../common/testing/test-utils";
 import type { Mocked } from "vitest";
+import { GuiConfigService } from 
"../../../../../common/service/gui-config.service";
+import { MockGuiConfigService } from 
"../../../../../common/service/gui-config.service.mock";
 
 // UserWorkflowListItemComponent is rooted at <nz-list-item>; instantiating it
 // outside an <nz-list> host throws "No provider found for NzListComponent".
@@ -289,3 +291,244 @@ describe("UserWorkflowListItemComponent", () => {
     return fixture.whenStable();
   }
 });
+/**
+ * The list item's template carries decisions the class does not: which 
control a click reaches, what
+ * value an edit forwards, whether a read-only row may still be edited inline, 
and which actions the
+ * row offers. The suite above calls the component's methods directly, so none 
of that was rendered.
+ */
+describe("UserWorkflowListItemComponent rendering", () => {
+  let fixture: ComponentFixture<TestHostComponent>;
+  let component: UserWorkflowListItemComponent;
+  let projectService: {
+    getProjectList: ReturnType<typeof vi.fn>;
+    removeWorkflowFromProject: ReturnType<typeof vi.fn>;
+  };
+
+  /**
+   * Projects are stored with a bare hex colour and the template prepends the 
'#'. The shared
+   * testUserProjects fixture already includes one, which makes every tag fail 
the format check and
+   * take the dark arm, so this suite supplies its own colours to reach both.
+   */
+  const PROJECTS = [
+    { pid: 1, name: "Light", description: "", ownerId: 1, color: "ffffff", 
creationTime: 0, accessLevel: "WRITE" },
+    { pid: 2, name: "Dark", description: "", ownerId: 1, color: "101010", 
creationTime: 0, accessLevel: "WRITE" },
+  ];
+
+  let persistService: { updateWorkflowName: ReturnType<typeof vi.fn> };
+
+  async function setup(opts: { executionsTracking?: boolean } = {}) {
+    // StubWorkflowPersistService does not declare updateWorkflowName, so it 
cannot be spied on.
+    persistService = { updateWorkflowName: vi.fn(() => of({} as Response)) };
+    projectService = {
+      getProjectList: vi.fn(() => of(PROJECTS as any)),
+      removeWorkflowFromProject: vi.fn(() => of({} as Response)),
+    };
+    TestBed.resetTestingModule();
+    await TestBed.configureTestingModule({
+      imports: [TestHostComponent, NzModalModule, HttpClientTestingModule, 
NzTooltipModule],
+      providers: [
+        { provide: WorkflowPersistService, useValue: persistService },
+        { provide: UserProjectService, useValue: projectService },
+        { provide: FileSaverService, useValue: { saveAs: vi.fn() } },
+        provideRouter([]),
+        ...commonTestProviders,
+      ],
+    }).compileComponents();
+    if (opts.executionsTracking) {
+      (TestBed.inject(GuiConfigService) as unknown as 
MockGuiConfigService).setConfig({
+        workflowExecutionsTrackingEnabled: true,
+      });
+    }
+  }
+
+  /** Renders one row. */
+  function render(entry: DashboardEntry, editable = true): HTMLElement {
+    fixture = TestBed.createComponent(TestHostComponent);
+    fixture.componentInstance.entry = entry;
+    fixture.componentInstance.editable = editable;
+    fixture.detectChanges();
+    component = fixture.componentInstance.inner;
+    return fixture.nativeElement as HTMLElement;
+  }
+
+  /**
+   * Tooltip titles on the row, read off the directive: ng-zorro consumes the 
nz-tooltip attribute,
+   * and an interpolated title is a property binding that never appears in the 
DOM at all.
+   */
+  function tooltipTitles(): unknown[] {
+    return fixture.debugElement
+      .queryAll(By.directive(NzTooltipDirective))
+      .map(d => (d.injector.get(NzTooltipDirective) as 
NzTooltipDirective).directiveTitle);
+  }
+
+  /** The elements whose tooltip title satisfies the predicate, in document 
order. */
+  function byTooltip(pred: (title: string) => boolean): HTMLElement[] {
+    return fixture.debugElement
+      .queryAll(By.directive(NzTooltipDirective))
+      .filter(d => {
+        const t = (d.injector.get(NzTooltipDirective) as 
NzTooltipDirective).directiveTitle;
+        return typeof t === "string" && pred(t);
+      })
+      .map(d => d.nativeElement as HTMLElement);
+  }
+
+  beforeEach(async () => {
+    await setup();
+  });
+
+  describe("renaming", () => {
+    it("forwards the text that was typed, not the name it started with", () => 
{
+      // The input is seeded with the current name, so binding workflow.name 
instead of the input's
+      // value would still look right on screen while silently discarding 
every rename.
+      render(makeWorkflowEntry({ wid: 7, name: "before" }));
+
+      component.editingName = true;
+      fixture.detectChanges();
+
+      const input = (fixture.nativeElement as 
HTMLElement).querySelector<HTMLInputElement>("input")!;
+      expect(input.value).toBe("before");
+      input.value = "after";
+      input.dispatchEvent(new Event("focusout"));
+
+      expect(persistService.updateWorkflowName).toHaveBeenCalledWith(7, 
"after");
+    });
+  });
+
+  describe("inline description editing", () => {
+    it("opens for a viewer who may edit", () => {
+      const el = render(makeWorkflowEntry(), true);
+
+      el.querySelector<HTMLElement>(".workflow-description")?.click();
+      fixture.detectChanges();
+
+      expect(component.editingDescription).toBe(true);
+    });
+
+    it("stays shut for a read-only viewer", () => {
+      // The gate is the template expression editingDescription = editable; 
without it a shared
+      // read-only row opens an editor whose save the backend then rejects.
+      const el = render(makeWorkflowEntry(), false);
+
+      el.querySelector<HTMLElement>(".workflow-description")?.click();
+      fixture.detectChanges();
+
+      expect(component.editingDescription).toBe(false);
+    });
+  });
+
+  describe("ownership", () => {
+    it("tells a non-owner what access they have and who shared it", () => {
+      const entry = makeWorkflowEntry();
+      entry.workflow.isOwner = false;
+      entry.workflow.accessLevel = "READ";
+      entry.workflow.ownerName = "Bob";
+      render(entry);
+
+      // Pins the order of the two interpolations.
+      expect(tooltipTitles()).toContain("READ access shared by Bob");
+    });
+
+    it("shows no shared-access marker to the owner", () => {
+      const entry = makeWorkflowEntry();
+      entry.workflow.isOwner = true;
+      render(entry);
+
+      expect(tooltipTitles().some(t => typeof t === "string" && 
t.includes("access shared by"))).toBe(false);
+    });
+
+    it("disables deleting a workflow the viewer does not own", () => {
+      const entry = makeWorkflowEntry();
+      entry.workflow.isOwner = false;
+      const el = render(entry);
+
+      
expect(el.querySelector<HTMLButtonElement>("button[nz-popconfirm]")?.disabled).toBe(true);
+    });
+  });
+
+  describe("project tags", () => {
+    it("removes the project whose tag was clicked, not the first one", () => {
+      const el = render(makeWorkflowEntry({ wid: 7 }, [1, 2]));
+
+      void el;
+      const removers = byTooltip(t => t === "Remove from project");
+      expect(removers.length).toBe(2);
+      // Second tag is pid 2; passing the loop variable is what makes this 
land on 2 and not 1.
+      removers[1].click();
+
+      expect(projectService.removeWorkflowFromProject).toHaveBeenCalledWith(2, 
7);
+    });
+
+    it("darkens the text on a light tag and lightens it on a dark one", () => {
+      // Asserted per tag rather than "both classes appear somewhere": 
inverting both arms merely
+      // swaps which tag gets which class, and a set-level check cannot see 
that.
+      render(makeWorkflowEntry({ wid: 7 }, [1, 2]));
+
+      const [lightTag] = byTooltip(t => t === "Light");
+      const [darkTag] = byTooltip(t => t === "Dark");
+
+      expect(lightTag.classList).toContain("light-color");
+      expect(lightTag.classList).not.toContain("dark-color");
+      expect(lightTag.style.color).toBe("black");
+
+      expect(darkTag.classList).toContain("dark-color");
+      expect(darkTag.classList).not.toContain("light-color");
+      expect(darkTag.style.color).toBe("white");
+    });
+  });
+
+  describe("row actions", () => {
+    it("withholds the executions action while execution tracking is off", () 
=> {
+      render(makeWorkflowEntry());
+
+      expect(tooltipTitles().some(t => typeof t === "string" && 
t.startsWith("Executions of the workflow"))).toBe(
+        false
+      );
+    });
+
+    it("offers the executions action once execution tracking is on", async () 
=> {
+      await setup({ executionsTracking: true });
+      render(makeWorkflowEntry());
+
+      expect(tooltipTitles().some(t => typeof t === "string" && 
t.startsWith("Executions of the workflow"))).toBe(true);
+    });
+
+    it("keeps duplicate and delete on their own outputs", () => {
+      // Adjacent icon buttons; emitting the wrong one of these would be 
destructive.
+      render(makeWorkflowEntry());
+      const duplicated = vi.fn();
+      const deleted = vi.fn();
+      component.duplicated.subscribe(duplicated);
+      component.deleted.subscribe(deleted);
+
+      const dup = byTooltip(t => t.startsWith("Duplicate"));
+      expect(dup.length).toBe(1);
+      dup[0].click();
+      expect(duplicated).toHaveBeenCalledTimes(1);
+      expect(deleted).not.toHaveBeenCalled();
+
+      
fixture.debugElement.query(By.css("button[nz-popconfirm]")).triggerEventHandler("nzOnConfirm",
 null);
+      expect(deleted).toHaveBeenCalledTimes(1);
+      expect(duplicated).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe("selection checkbox", () => {
+    it("indents the avatar only when the checkbox is absent", () => {
+      const withBox = render(makeWorkflowEntry(), 
true).querySelector<HTMLElement>("nz-list-item-meta-avatar")!;
+      expect(withBox.style.marginLeft).not.toBe("16px");
+
+      const withoutBox = render(makeWorkflowEntry(), 
false).querySelector<HTMLElement>("nz-list-item-meta-avatar")!;
+      expect(withoutBox.style.marginLeft).toBe("16px");
+      expect(withoutBox.querySelector(".workflow-item-checkbox")).toBeNull();
+    });
+
+    it("records the selection on the entry", () => {
+      const entry = makeWorkflowEntry();
+      render(entry, true);
+
+      
fixture.debugElement.query(By.css(".workflow-item-checkbox")).triggerEventHandler("ngModelChange",
 true);
+
+      expect(entry.checked).toBe(true);
+    });
+  });
+});

Reply via email to