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-7772-2d60462a9527e5d551b53a94b7875cacfc8e5018
in repository https://gitbox.apache.org/repos/asf/texera.git

commit aed20af18815a2173917222cdfa7445916b74131
Author: Xinyuan Lin <[email protected]>
AuthorDate: Wed Aug 19 06:27:56 2026 +0000

    test(frontend): render the small templates instead of calling their 
handlers (#7772)
    
    ### What changes were proposed in this PR?
    
    Nine small templates and one parser service. In every case the existing
    specs drove the component's methods directly and never rendered or
    clicked, so the templates' event bindings were unexecuted — 35 tests now
    render them and assert on the DOM.
    
    | File | Before | After |
    |---|---|---|
    | `dataset-version-selector.component.html` | 5/8 | **8/8** |
    | `settings.component.html` | 22/24 | **24/24** |
    | `versions-list.component.html` | 26/28 | **28/28** |
    | `mini-map.component.html` | 23/30 | **30/30** |
    | `sort-button.component.html` | 25/32 | **32/32** |
    | `user-dataset-version-creator.component.html` | 31/37 | **37/37** |
    | `registration-request-modal.component.html` | 2 fns + 2 branches dead
    | **all covered** |
    | `dataset-selection-modal.component.html` | 34/38 | **38/38** |
    | `ui-udf-parameters-parser.service.ts` | 168/172 | **170/172** |
    
    Suite 4739 -> **4774 tests**, 201 files, all green. Globally: statements
    93.61 -> **93.74%**, branches 89.45 -> **89.66%**, functions 89.30 ->
    **89.83%**, lines 95.38 -> **95.44%**.
    
    **None of these is an instance of #7458** — no target spec uses
    `TestBed.overrideComponent`, so there was no attribution loss to
    recover. That distinction is worth recording, because six merged PRs
    have now applied the #7458 remedy and it would have been the wrong tool
    here.
    
    ### Verification
    
    21 mutations applied one at a time, **20 killed, 1 equivalent**, with
    the production file diffed clean after every revert and the failing test
    named.
    
    The bulk are true **exchanges** rather than constant substitutions,
    which is what makes them meaningful on templates where two handlers sit
    side by side:
    
    | Mutation | Killed by |
    |---|---|
    | exchange `onClickZoomOut()` and `onClickZoomIn()` | zooms out and in
    from their own toolbar buttons |
    | exchange the drag handlers `onDrag($event)` / `dragging = false` |
    pans the main paper…; freezes the navigator between drag start and end |
    | exchange the icon ternary legs `'global'` / `'minus'` | collapses and
    re-opens the mini-map |
    | exchange `lastSort()` and `dateSort()` | emits the sort method that
    matches the clicked row |
    | exchange `onPublicStatusChange` and `onDownloadableStatusChange` |
    routes the first switch…; routes the second switch… |
    | exchange `[(ngModel)]="affiliation"` and `"reason"` | collects what
    the user typed into each box, trimmed |
    | exchange the `isOwner ? 'OWNER' : accessPrivilege` legs | labels an
    owned dataset OWNER and a shared one by its privilege |
    | drop `&& …?.touched` from the error guard | stays quiet about an
    invalid batch size the user has not touched yet |
    | `[disabled]="!selectedPath"` -> `"!!selectedPath"` | takes the path
    from the file tree's selection in file mode |
    | parser: exchange the two `UiUdfParametersEditError` messages | refuses
    to insert into a class that has no block body at all |
    
    **The equivalent mutant** is the `readMemberPath` MemberExpression
    guard: it is redundant with the child filter two lines below, since
    every node reaching it is either a MemberExpression or an argument value
    carrying no direct `VariableName`/`PropertyName` children — so `parts`
    comes back empty and the function returns `undefined` either way. The
    new test still pins real behaviour; it just cannot distinguish that
    guard.
    
    **One mutation was discarded rather than counted:** `if (first ||
    !body)` -> `if (first)` is a TS18047 compile error, because that guard
    is what narrows `body` to non-null. A mutation that only fails to
    compile proves nothing, so it was replaced with the message exchange
    above.
    
    ### Deliberately not included
    
    **`hugging-face.component.html` (rejected entirely) — a production bug,
    reported not fixed.** Its validation message is gated on
    `*ngIf="props.showError && formControl.errors"`, but `props.showError`
    is never assigned anywhere in the repo and `@ngx-formly` never populates
    it: `showError` is a getter on `FieldType`, which the sibling
    `common/formly/object.type.ts:23` and `multischema.type.ts:24` use
    correctly **without** the `props.` prefix. So
    `<formly-validation-message>` never renders on the Hugging Face model
    field. Covering those lines would cement the typo.
    
    **The parser's remaining six branch arms are unreachable**, verified by
    probing `@lezer/python`'s error recovery across ~20 malformed sources
    rather than by inspection: a dangling `name=` always gains a zero-width
    error node, so `!value` cannot fire; an `AssignOp` inside an `ArgList`
    is always immediately preceded by a `VariableName` (`self.x=1`, `"a"=1`
    and `f(1)=2` all split the CallExpression; `None=1` yields an error
    node, not an `AssignOp`); `|| "parameter"` only fires for `""`, which an
    earlier guard rejects first; `/^[ \t]*/` always matches so its `?? ""`
    is dead; and both `parts` guards are fed by nodes that always carry the
    relevant child.
    
    A stale comment was also corrected:
    `ui-udf-parameters-parser.service.spec.ts:432` claimed to drive
    `lineEnd()`'s `newline === -1` arm, but coverage showed that arm at 0
    hits. It now has a test that really drives it — a docstring ending the
    file with no trailing newline.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7771
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false
    ```
    
    ```
     Test Files  201 passed (201)
          Tests  4774 passed | 1 skipped (4775)
    ```
    
    `yarn format:ci` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../registration-request-modal.component.spec.ts   |  66 ++++++++++++
 .../user/sort-button/sort-button.component.spec.ts |  72 ++++++++++++-
 .../user-dataset-version-creator.component.spec.ts |  72 +++++++++++++
 .../dataset-selection-modal.component.spec.ts      | 106 +++++++++++++++++++
 .../dataset-version-selector.component.spec.ts     |  41 ++++++++
 .../left-panel/settings/settings.component.spec.ts |  48 +++++++++
 .../versions-list/versions-list.component.spec.ts  |  21 ++++
 .../mini-map/mini-map.component.spec.ts            | 114 +++++++++++++++++++++
 .../ui-udf-parameters-parser.service.spec.ts       |  88 +++++++++++++++-
 9 files changed, 623 insertions(+), 5 deletions(-)

diff --git 
a/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.spec.ts
 
b/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.spec.ts
index 5166382436..4d2205f89c 100644
--- 
a/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.spec.ts
+++ 
b/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.spec.ts
@@ -82,4 +82,70 @@ describe("RegistrationRequestModalComponent", () => {
     expect(logo.getAttribute("src")).toBe("assets/logos/full_logo_small.png");
     expect(logo.getAttribute("alt")).toBe("Texera logo");
   });
+
+  /**
+   * The specs above assign the fields on the instance, which is the direction
+   * the form is never driven in: a real request is typed into the two editable
+   * boxes and read back out through getValues(). Nothing pinned that the boxes
+   * write back at all, nor which box feeds which value.
+   */
+  describe("rendered form", () => {
+    /** The four inputs in template order: name, email, affiliation, reason. */
+    async function renderForm(): Promise<{
+      fixture: ComponentFixture<RegistrationRequestModalComponent>;
+      name: HTMLInputElement;
+      email: HTMLInputElement;
+      affiliation: HTMLInputElement;
+      reason: HTMLTextAreaElement;
+    }> {
+      const fixture = await createFixture({ uid: 1, email: "[email protected]", name: 
"Alice" });
+      fixture.detectChanges();
+      const [name, email, affiliation] = Array.from(
+        fixture.nativeElement.querySelectorAll("input[nz-input]")
+      ) as HTMLInputElement[];
+      const reason = fixture.nativeElement.querySelector("textarea[nz-input]") 
as HTMLTextAreaElement;
+      return { fixture, name, email, affiliation, reason };
+    }
+
+    /** Types `text` into `element` the way a user would. */
+    function type(element: HTMLInputElement | HTMLTextAreaElement, text: 
string): void {
+      element.value = text;
+      element.dispatchEvent(new Event("input"));
+    }
+
+    it("shows the signed-in identity read-only and leaves the request fields 
open", async () => {
+      const { name, email, affiliation, reason } = await renderForm();
+
+      expect(name.value).toBe("Alice");
+      expect(email.value).toBe("[email protected]");
+      // The administrator reviews the account the user is actually signed in 
as,
+      // so neither identity field may be edited.
+      expect(name.disabled).toBe(true);
+      expect(email.disabled).toBe(true);
+      expect(affiliation.disabled).toBe(false);
+      expect(reason.disabled).toBe(false);
+      expect(affiliation.getAttribute("placeholder")).toBe("e.g. UC Irvine");
+      expect(reason.getAttribute("placeholder")).toBe("Briefly explain why you 
want access");
+    });
+
+    it("collects what the user typed into each box, trimmed", async () => {
+      const { fixture, affiliation, reason } = await renderForm();
+
+      // Distinct values, so a swapped binding cannot pass.
+      type(affiliation, "  UC Irvine  ");
+      type(reason, "  research collaboration  ");
+      fixture.detectChanges();
+
+      expect(fixture.componentInstance.getValues()).toEqual({
+        affiliation: "UC Irvine",
+        reason: "research collaboration",
+      });
+    });
+
+    it("reports an untouched form as empty rather than as the identity 
fields", async () => {
+      const { fixture } = await renderForm();
+
+      expect(fixture.componentInstance.getValues()).toEqual({ affiliation: "", 
reason: "" });
+    });
+  });
 });
diff --git 
a/frontend/src/app/dashboard/component/user/sort-button/sort-button.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/sort-button/sort-button.component.spec.ts
index 10ad6c1fac..1e8672742b 100644
--- 
a/frontend/src/app/dashboard/component/user/sort-button/sort-button.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/sort-button/sort-button.component.spec.ts
@@ -18,6 +18,8 @@
  */
 
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { NzDropdownMenuComponent } from "ng-zorro-antd/dropdown";
 import { SortButtonComponent } from "./sort-button.component";
 import { SortMethod } from "../../../type/sort-method";
 
@@ -77,10 +79,6 @@ describe("SortButtonComponent", () => {
     expect(emitSpy).toHaveBeenCalledWith(SortMethod.ExecutionTimeDesc);
   });
 
-  // Note: the sort options render inside an nz-dropdown-menu (a CDK overlay) 
that
-  // does not attach under the vitest/jsdom test environment, so we can't 
assert on
-  // the rendered menu text. We instead verify the input contract that the 
template's
-  // @if guards bind to (showEditTime / showExecutionTime).
   it("shows the edit-time and execution-time options by default (e.g. for 
workflows)", () => {
     expect(component.showEditTime).toBe(true);
     expect(component.showExecutionTime).toBe(true);
@@ -93,4 +91,70 @@ describe("SortButtonComponent", () => {
     expect(component.showEditTime).toBe(false);
     expect(component.showExecutionTime).toBe(false);
   });
+
+  /**
+   * The options live in an nz-dropdown-menu, whose content is an <ng-template>
+   * that only mounts into a CDK overlay when the dropdown opens — jsdom never
+   * drives that, so none of the menu ever rendered and the tests above could
+   * only check the component's own methods. Instantiating the menu template
+   * directly puts the rows in the fixture's DOM, so the @if guards, the labels
+   * and the per-row (click) bindings all really run: re-pointing a row at the
+   * wrong sort method, or dropping a guard, fails here.
+   */
+  describe("rendered sort menu", () => {
+    /** Mounts the dropdown menu template into the fixture and returns its 
rows. */
+    function renderMenu(): HTMLButtonElement[] {
+      const menu = 
fixture.debugElement.query(By.directive(NzDropdownMenuComponent))
+        .componentInstance as NzDropdownMenuComponent;
+      menu.viewContainerRef.createEmbeddedView(menu.templateRef);
+      fixture.detectChanges();
+      return 
Array.from(fixture.nativeElement.querySelectorAll("li[nz-menu-item] button"));
+    }
+
+    const labelsOf = (rows: HTMLButtonElement[]): string[] => rows.map(row => 
row.textContent!.trim());
+
+    it("lists every sort option when the resource has both timestamps", () => {
+      expect(labelsOf(renderMenu())).toEqual([
+        "By Edit Time",
+        "By Create Time",
+        "By Execution Time",
+        "A -> Z",
+        "Z -> A",
+      ]);
+    });
+
+    it("drops only the edit-time option when the resource has no edit 
timestamp", () => {
+      component.showEditTime = false;
+
+      expect(labelsOf(renderMenu())).toEqual(["By Create Time", "By Execution 
Time", "A -> Z", "Z -> A"]);
+    });
+
+    it("drops only the execution-time option when the resource has no 
execution timestamp", () => {
+      component.showExecutionTime = false;
+
+      expect(labelsOf(renderMenu())).toEqual(["By Edit Time", "By Create 
Time", "A -> Z", "Z -> A"]);
+    });
+
+    it("emits the sort method that matches the clicked row", () => {
+      const emitted: SortMethod[] = [];
+      component.sortMethodChange.subscribe(method => emitted.push(method));
+      const rows = renderMenu();
+      const labels = labelsOf(rows);
+
+      // Click every row in turn: each label must reach its own sort method, so
+      // two rows wired to the same handler cannot pass.
+      rows.forEach(row => row.click());
+
+      expect(labels).toHaveLength(5);
+      expect(emitted).toEqual([
+        SortMethod.EditTimeDesc,
+        SortMethod.CreateTimeDesc,
+        SortMethod.ExecutionTimeDesc,
+        SortMethod.NameAsc,
+        SortMethod.NameDesc,
+      ]);
+      // ... and the last click is the state the button keeps.
+      expect(component.sortMethod).toBe(SortMethod.NameDesc);
+    });
+  });
 });
diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.spec.ts
index 9be52667c6..1c7ebdd007 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.spec.ts
@@ -19,6 +19,7 @@
 
 import { Component } from "@angular/core";
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
 import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
 import { FormsModule, ReactiveFormsModule } from "@angular/forms";
 import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core";
@@ -191,4 +192,75 @@ describe("UserDatasetVersionCreatorComponent", () => {
     expect(component.isDatasetPublic).toBe(true);
     expect(component.isDatasetDownloadable).toBe(true);
   });
+
+  /**
+   * Every test above calls the component's methods directly, so the template's
+   * own wiring never ran: which button submits and which cancels, and which
+   * nz-switch feeds which flag. These go through the rendered controls, so
+   * swapping the two switches or the two buttons fails here.
+   */
+  describe("rendered controls", () => {
+    /** Renders in dataset-creation mode (the only mode that shows the 
switches). */
+    async function renderCreator(): 
Promise<ComponentFixture<UserDatasetVersionCreatorComponent>> {
+      const fixture = await createFixture({ isCreatingVersion: false });
+      fixture.detectChanges();
+      fixture.componentInstance.form.get("name")?.setValue("My Dataset");
+      fixture.detectChanges();
+      return fixture;
+    }
+
+    const switches = (fixture: 
ComponentFixture<UserDatasetVersionCreatorComponent>) =>
+      fixture.debugElement.queryAll(By.css("nz-switch"));
+
+    const click = (fixture: 
ComponentFixture<UserDatasetVersionCreatorComponent>, selector: string) =>
+      (fixture.nativeElement.querySelector(selector) as 
HTMLButtonElement).click();
+
+    it("submits the dataset from the create button", async () => {
+      const fixture = await renderCreator();
+      createDataset.mockReturnValue(of({ did: 7 }));
+
+      click(fixture, "button.create-btn");
+
+      expect(createDataset).toHaveBeenCalledTimes(1);
+      expect(modalClose).toHaveBeenCalledWith({ did: 7 });
+    });
+
+    it("dismisses the modal from the cancel button without creating anything", 
async () => {
+      const fixture = await renderCreator();
+
+      click(fixture, "button.cancel-btn");
+
+      expect(createDataset).not.toHaveBeenCalled();
+      expect(modalClose).toHaveBeenCalledWith(null);
+    });
+
+    it("routes the first switch to the dataset's visibility only", async () => 
{
+      const fixture = await renderCreator();
+      createDataset.mockReturnValue(of({ did: 7 }));
+
+      switches(fixture)[0].triggerEventHandler("ngModelChange", true);
+      click(fixture, "button.create-btn");
+
+      expect(createDataset.mock.calls[0][0]).toMatchObject({ isPublic: true, 
isDownloadable: false });
+    });
+
+    it("routes the second switch to the dataset's downloadability only", async 
() => {
+      const fixture = await renderCreator();
+      createDataset.mockReturnValue(of({ did: 7 }));
+
+      switches(fixture)[1].triggerEventHandler("ngModelChange", true);
+      click(fixture, "button.create-btn");
+
+      expect(createDataset.mock.calls[0][0]).toMatchObject({ isPublic: false, 
isDownloadable: true });
+    });
+
+    it("offers no visibility or downloadability switch when adding a version", 
async () => {
+      const fixture = await createFixture({ isCreatingVersion: true, did: 5 });
+      fixture.detectChanges();
+
+      // A version inherits both flags from its dataset, so the toggles are 
hidden.
+      expect(switches(fixture)).toHaveLength(0);
+      
expect(fixture.nativeElement.querySelector("button.create-btn")).not.toBeNull();
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/dataset-selection-modal/dataset-selection-modal.component.spec.ts
 
b/frontend/src/app/workspace/component/dataset-selection-modal/dataset-selection-modal.component.spec.ts
index 1eb0d1e646..604d86a9f9 100644
--- 
a/frontend/src/app/workspace/component/dataset-selection-modal/dataset-selection-modal.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/dataset-selection-modal/dataset-selection-modal.component.spec.ts
@@ -18,8 +18,11 @@
  */
 
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
 import { of } from "rxjs";
+import { NzOptionComponent, NzSelectComponent } from "ng-zorro-antd/select";
 import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal";
+import { UserDatasetVersionFiletreeComponent } from 
"../../../dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component";
 import { DatasetSelectionModalComponent } from 
"./dataset-selection-modal.component";
 import { DatasetService } from 
"../../../dashboard/service/user/dataset/dataset.service";
 import { DashboardDataset } from 
"../../../dashboard/type/dashboard-dataset.interface";
@@ -215,4 +218,107 @@ describe("DatasetSelectionModalComponent", () => {
 
     expect(component.selectedPath).toBe("/kept");
   });
+
+  /**
+   * The tests above call the handlers directly, so the template's wiring never
+   * ran: which dropdown writes which model, that picking a dataset re-runs the
+   * version lookup, that the file tree's selection reaches the modal, and that
+   * the Select button is what closes it. These drive the rendered controls.
+   */
+  describe("rendered modal", () => {
+    /** A dataset shared with the user rather than owned by them. */
+    const sharedDataset: DashboardDataset = {
+      ...dataset,
+      isOwner: false,
+      accessPrivilege: "READ",
+      ownerEmail: "[email protected]",
+      dataset: { ...dataset.dataset, did: 20, name: "sharedds" },
+    };
+
+    const sharedVersion: DatasetVersion = { ...version, dvid: 200, did: 20, 
name: "v9" };
+
+    const selects = () => 
fixture.debugElement.queryAll(By.directive(NzSelectComponent));
+    const confirmButton = () => 
fixture.nativeElement.querySelector("button[nz-button]") as HTMLButtonElement;
+
+    /** The <nz-option>s the template's *ngFor produced for the given 
dropdown. */
+    const optionsOf = (index: number): NzOptionComponent[] =>
+      (selects()[index].componentInstance as 
NzSelectComponent).listOfNzOptionComponent.toArray();
+
+    /**
+     * The custom option content only mounts once the dropdown is open, and the
+     * dropdown is a CDK overlay that lands in the document body rather than in
+     * the fixture. Two change-detection passes: the first attaches the overlay
+     * portal, the second renders the options into it.
+     */
+    function renderOptionRows(): HTMLElement[] {
+      (selects()[0].componentInstance as NzSelectComponent).setOpenState(true);
+      fixture.detectChanges();
+      fixture.detectChanges();
+      return Array.from(document.querySelectorAll(".cdk-overlay-container 
.dataset-row"));
+    }
+
+    it("labels an owned dataset OWNER and a shared one by its access 
privilege", () => {
+      datasetService.retrieveAccessibleDatasets.mockReturnValue(of([dataset, 
sharedDataset]));
+      build();
+
+      const rows = renderOptionRows();
+
+      expect(rows.map(row => 
row.querySelector(".dataset-name")!.textContent!.replace(/\s+/g, " 
").trim())).toEqual([
+        "#10 myds",
+        "#20 sharedds",
+      ]);
+      expect(rows.map(row => 
row.querySelector(".access-level")!.textContent!.trim())).toEqual(["OWNER", 
"READ"]);
+    });
+
+    it("looks up the versions of whichever dataset the first dropdown 
reports", () => {
+      datasetService.retrieveAccessibleDatasets.mockReturnValue(of([dataset, 
sharedDataset]));
+      
datasetService.retrieveDatasetVersionList.mockReturnValue(of([sharedVersion]));
+      build();
+
+      selects()[0].triggerEventHandler("ngModelChange", sharedDataset);
+      fixture.detectChanges();
+
+      
expect(datasetService.retrieveDatasetVersionList).toHaveBeenCalledWith(20);
+      // the version dropdown is repopulated from the newly chosen dataset
+      expect(optionsOf(1).map(option => option.nzLabel)).toEqual(["v9"]);
+    });
+
+    it("enables the Select button once the second dropdown reports a version", 
() => {
+      build(); // non-file mode: the path is composed from the dataset and 
version
+      selects()[0].triggerEventHandler("ngModelChange", dataset);
+      fixture.detectChanges();
+      expect(confirmButton().disabled).toBe(true);
+
+      selects()[1].triggerEventHandler("ngModelChange", version);
+      fixture.detectChanges();
+
+      expect(confirmButton().disabled).toBe(false);
+    });
+
+    it("closes the modal with the composed path when Select is clicked", () => 
{
+      build();
+      selects()[0].triggerEventHandler("ngModelChange", dataset);
+      selects()[1].triggerEventHandler("ngModelChange", version);
+      fixture.detectChanges();
+
+      confirmButton().click();
+
+      
expect(modalRef.close).toHaveBeenCalledWith(`/datasets/${OWNER}/myds/v1`);
+    });
+
+    it("takes the path from the file tree's selection in file mode", () => {
+      modalData.fileMode = true;
+      modalData.selectedPath = `/datasets/${OWNER}/myds/v1`;
+      build();
+
+      fixture.debugElement
+        .query(By.directive(UserDatasetVersionFiletreeComponent))
+        .triggerEventHandler("selectedTreeNode", fileNode);
+      fixture.detectChanges();
+
+      expect(confirmButton().disabled).toBe(false);
+      confirmButton().click();
+      expect(modalRef.close).toHaveBeenCalledWith(`/${OWNER}/myds/v1/a.csv`);
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/dataset-version-selector/dataset-version-selector.component.spec.ts
 
b/frontend/src/app/workspace/component/dataset-version-selector/dataset-version-selector.component.spec.ts
index fc25b6b0b5..394e9773cc 100644
--- 
a/frontend/src/app/workspace/component/dataset-version-selector/dataset-version-selector.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/dataset-version-selector/dataset-version-selector.component.spec.ts
@@ -82,4 +82,45 @@ describe("DatasetVersionSelectorComponent", () => {
 
     expect(formControl.value).toBe("/existing/v2");
   });
+
+  /**
+   * The tests above call onClickOpenDatasetSelectionModal directly, so the
+   * template never rendered: the read-only box that shows the current 
selection
+   * and the button that opens the picker were both unexercised.
+   */
+  describe("rendered field", () => {
+    const selectionBox = (): HTMLInputElement | null => 
fixture.nativeElement.querySelector("input[nz-input]");
+    const openButton = (): HTMLButtonElement => 
fixture.nativeElement.querySelector("button[nz-button]");
+
+    it("shows nothing but the picker button until a dataset has been chosen", 
() => {
+      setFormControl("");
+      fixture.detectChanges();
+
+      expect(selectionBox()).toBeNull();
+      expect(openButton().textContent!.trim()).toBe("Select Dataset");
+    });
+
+    it("shows the chosen path in a read-only box", () => {
+      setFormControl("/datasets/[email protected]/myds/v1");
+      fixture.detectChanges();
+
+      const box = selectionBox()!;
+      expect(box.value).toBe("/datasets/[email protected]/myds/v1");
+      // The path is only editable through the picker, never by typing.
+      expect(box.readOnly).toBe(true);
+      expect(box.required).toBe(true);
+    });
+
+    it("opens the picker from the button and shows whatever it returns", () => 
{
+      setFormControl("");
+      fixture.detectChanges();
+      modalServiceSpy.create.mockReturnValue({ afterClose: 
of("/datasets/[email protected]/myds/v2") });
+
+      openButton().click();
+      fixture.detectChanges();
+
+      expect(modalServiceSpy.create).toHaveBeenCalledTimes(1);
+      expect(selectionBox()!.value).toBe("/datasets/[email protected]/myds/v2");
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/left-panel/settings/settings.component.spec.ts
 
b/frontend/src/app/workspace/component/left-panel/settings/settings.component.spec.ts
index c504071e21..7d73319ab9 100644
--- 
a/frontend/src/app/workspace/component/left-panel/settings/settings.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/left-panel/settings/settings.component.spec.ts
@@ -187,4 +187,52 @@ describe("SettingsComponent", () => {
 
     expect(setBatchSizeSpy).not.toHaveBeenCalled();
   });
+
+  /**
+   * The validation feedback lives entirely in the template — an is-invalid 
class
+   * and an explanatory message, both gated on the control being invalid *and*
+   * touched. The specs above only inspect the control, so nothing pinned the
+   * rendered feedback or the "only after the user has been there" guard.
+   */
+  describe("rendered validation feedback", () => {
+    const batchSizeInput = (): HTMLInputElement => 
fixture.nativeElement.querySelector("#dataTransferBatchSize");
+    const errorMessage = (): HTMLElement | null => 
fixture.nativeElement.querySelector(".error-message");
+
+    it("stays quiet while the batch size is valid", () => {
+      expect(errorMessage()).toBeNull();
+      expect(batchSizeInput().classList).not.toContain("is-invalid");
+    });
+
+    it("stays quiet about an invalid batch size the user has not touched yet", 
() => {
+      component.settingsForm.get("dataTransferBatchSize")!.setValue(0);
+      fixture.detectChanges();
+
+      expect(errorMessage()).toBeNull();
+      expect(batchSizeInput().classList).not.toContain("is-invalid");
+    });
+
+    it("explains the minimum once the user has touched an invalid batch size", 
() => {
+      const control = component.settingsForm.get("dataTransferBatchSize")!;
+      control.setValue(0);
+      control.markAsTouched();
+      fixture.detectChanges();
+
+      expect(errorMessage()!.textContent!.trim()).toBe("Data Transfer Batch 
Size size must be at least 1.");
+      expect(batchSizeInput().classList).toContain("is-invalid");
+    });
+
+    it("withdraws the message once a valid batch size is typed back in", () => 
{
+      const control = component.settingsForm.get("dataTransferBatchSize")!;
+      control.setValue(0);
+      control.markAsTouched();
+      fixture.detectChanges();
+      expect(errorMessage()).not.toBeNull();
+
+      control.setValue(50);
+      fixture.detectChanges();
+
+      expect(errorMessage()).toBeNull();
+      expect(batchSizeInput().classList).not.toContain("is-invalid");
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/left-panel/versions-list/versions-list.component.spec.ts
 
b/frontend/src/app/workspace/component/left-panel/versions-list/versions-list.component.spec.ts
index c9a2af8af8..827c41e4db 100644
--- 
a/frontend/src/app/workspace/component/left-panel/versions-list/versions-list.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/left-panel/versions-list/versions-list.component.spec.ts
@@ -18,6 +18,7 @@
  */
 
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
 import { WorkflowActionService } from 
"../../../service/workflow-graph/model/workflow-action.service";
 import { DEFAULT_WORKFLOW } from 
"../../../service/workflow-graph/model/workflow-action.service";
 import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
@@ -298,5 +299,25 @@ describe("VersionsListComponent", () => {
         /^\d{2}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/
       );
     });
+
+    it("reveals and re-hides the minor versions behind the expand control", () 
=> {
+      // collapse() is driven directly in its own describe above; this goes 
through
+      // the table's expand control, which is the binding that would break if
+      // (nzExpandChange) or the [(nzExpand)] write-back were dropped.
+      renderRows([makeEntry(3, true), makeEntry(2, false, false), makeEntry(1, 
false, false)]);
+      const expandCell = fixture.debugElement.query(By.css("td.version-link"));
+
+      expandCell.triggerEventHandler("nzExpandChange", true);
+      fixture.detectChanges();
+
+      expect(fixture.nativeElement.querySelectorAll("tbody 
tr")).toHaveLength(3);
+      expect(component.versionsList![0].expand).toBe(true);
+
+      expandCell.triggerEventHandler("nzExpandChange", false);
+      fixture.detectChanges();
+
+      expect(fixture.nativeElement.querySelectorAll("tbody 
tr")).toHaveLength(1);
+      expect(component.versionsList![0].expand).toBe(false);
+    });
   });
 });
diff --git 
a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts
 
b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts
index 2087b5e72b..0a5c31c694 100644
--- 
a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts
@@ -18,6 +18,7 @@
  */
 
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { ReplaySubject } from "rxjs";
 import * as joint from "jointjs";
@@ -412,4 +413,117 @@ describe("MiniMapComponent", () => {
       expect(reset).toHaveBeenCalledTimes(1);
     });
   });
+
+  /**
+   * Everything above drives the component's methods directly, so the 
template's
+   * own wiring — which toolbar button calls which method, and which cdkDrag
+   * output feeds onDrag / the `dragging` flag — was never executed. These 
tests
+   * go through the rendered DOM instead, so re-pointing a (click) at the wrong
+   * handler, or dropping one of the cdkDrag bindings, fails here.
+   */
+  describe("toolbar and drag wiring", () => {
+    const button = (id: string): HTMLButtonElement =>
+      fixture.nativeElement.querySelector(`#${id}`) as HTMLButtonElement;
+
+    /** The mini-map surface, whose visibility the toggle button drives. */
+    const container = (): HTMLElement => 
fixture.nativeElement.querySelector("#mini-map-container") as HTMLElement;
+
+    /** ng-zorro renders <span nz-icon nzType="x"> as class "anticon-x". */
+    const toggleIconType = (): string | undefined =>
+      
Array.from(button("minimap-button").querySelector("span[nz-icon]")!.classList)
+        .find(name => name.startsWith("anticon-"))
+        ?.slice("anticon-".length);
+
+    it("collapses and re-opens the mini-map from the toolbar toggle", () => {
+      fixture.detectChanges();
+      expect(container().hidden).toBe(false);
+      expect(toggleIconType()).toBe("minus");
+
+      button("minimap-button").click();
+      fixture.detectChanges();
+
+      expect(container().hidden).toBe(true);
+      // The glyph flips to the "show me" affordance while the map is folded 
away.
+      expect(toggleIconType()).toBe("global");
+
+      button("minimap-button").click();
+      fixture.detectChanges();
+
+      expect(container().hidden).toBe(false);
+      expect(toggleIconType()).toBe("minus");
+    });
+
+    it("broadcasts a center event from the center button only", () => {
+      fixture.detectChanges();
+      const centerEvents: void[] = [];
+      workflowActionService
+        .getTexeraGraph()
+        .getCenterEventStream()
+        .subscribe(event => centerEvents.push(event));
+
+      // The three other toolbar buttons sit next to it and must not centre.
+      button("minimap-button").click();
+      button("minimap-zoom-in-button").click();
+      button("minimap-zoom-out-button").click();
+      expect(centerEvents).toHaveLength(0);
+
+      button("minimap-center-button").click();
+
+      expect(centerEvents).toHaveLength(1);
+    });
+
+    it("zooms out and in from their own toolbar buttons", () => {
+      fixture.detectChanges();
+      const jointGraphWrapper = workflowActionService.getJointGraphWrapper();
+      jointGraphWrapper.setZoomProperty(1);
+
+      button("minimap-zoom-out-button").click();
+      expect(jointGraphWrapper.getZoomRatio()).toBeCloseTo(1 - 
JointGraphWrapper.ZOOM_CLICK_DIFF, 10);
+
+      // Two zoom-ins from here land one step *above* the starting ratio, so 
the
+      // two buttons cannot be swapped without breaking this.
+      button("minimap-zoom-in-button").click();
+      button("minimap-zoom-in-button").click();
+      expect(jointGraphWrapper.getZoomRatio()).toBeCloseTo(1 + 
JointGraphWrapper.ZOOM_CLICK_DIFF, 10);
+    });
+
+    it("pans the main paper from the navigator's cdkDragMoved output", () => {
+      fixture.detectChanges();
+      const paper = new StubPaper();
+      paper.offset = { tx: 100, ty: 50 };
+      component.paper = paper as unknown as joint.dia.Paper;
+      component.scale = 0.25;
+
+      fixture.debugElement
+        .query(By.css("#mini-map-navigator"))
+        .triggerEventHandler("cdkDragMoved", { event: { movementX: 10, 
movementY: -20 } });
+
+      expect(paper.translateArgs).toEqual([[100 - 40, 50 + 80]]);
+    });
+
+    it("freezes the navigator between cdkDragStarted and cdkDragEnded", () => {
+      mountWorkflowEditorStub(800, 600, 30, 40);
+      fixture.detectChanges();
+      component.scale = 0.25;
+      const paper = new StubPaper(2, 4);
+      paper.localPoint = { x: -160, y: -140 };
+      attachMainPaper(paper);
+
+      const navigator = document.getElementById("mini-map-navigator") as 
HTMLElement;
+      const navigatorDebugElement = 
fixture.debugElement.query(By.css("#mini-map-navigator"));
+      expect(navigator.style.left).toBe("200px");
+
+      // While the pointer owns the navigator, echoes of the paper's own 
translate
+      // must not fight it.
+      navigatorDebugElement.triggerEventHandler("cdkDragStarted", {});
+      paper.localPoint = { x: -560, y: -140 };
+      paper.handlers["translate"]();
+      expect(navigator.style.left).toBe("200px");
+
+      // Once the drag ends the navigator tracks the paper again.
+      navigatorDebugElement.triggerEventHandler("cdkDragEnded", {});
+      paper.handlers["translate"]();
+      expect(navigator.style.left).toBe("100px");
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts
 
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts
index 75cf20648e..94a92148ad 100644
--- 
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts
+++ 
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts
@@ -429,7 +429,7 @@ describe("UiUdfParametersParserService degenerate sources", 
() => {
   });
 
   it("should handle a declaration on a final line with no trailing newline", 
() => {
-    // lineEnd() takes its `newline === -1` arm when the statement ends the 
file
+    // the declaration goes in above `pass`, which is the last line of the file
     const code = "class ProcessTupleOperator(UDFOperatorV2):\n    def 
open(self):\n        pass";
 
     const updated = insertParameter(service, code, "threshold");
@@ -456,6 +456,92 @@ describe("UiUdfParametersParserService degenerate 
sources", () => {
 
     expect(service.parse(code)).toEqual([]);
   });
+
+  it("should ignore a UiParameter call that supplies the same argument twice", 
() => {
+    // attr_type is an alias of type, so this call carries two types; the 
positional
+    // "x" is the name, so the second one carries two names. Both are 
ambiguous, and
+    // guessing which one wins would silently declare the wrong parameter.
+    const twoTypes = pythonLines(
+      "class ProcessTupleOperator(UDFOperatorV2):",
+      "    def open(self):",
+      '        self.UiParameter(name="x", type=AttributeType.DOUBLE, 
attr_type=AttributeType.INT)'
+    );
+    const twoNames = pythonLines(
+      "class ProcessTupleOperator(UDFOperatorV2):",
+      "    def open(self):",
+      '        self.UiParameter("x", name="y", type=AttributeType.DOUBLE)'
+    );
+
+    expect(service.parse(twoTypes)).toEqual([]);
+    expect(service.parse(twoNames)).toEqual([]);
+  });
+
+  it("should ignore a type given as a plain string instead of an AttributeType 
member", () => {
+    // "double" is not a member path, so there is nothing to resolve against 
the
+    // AttributeType receiver — the call is skipped rather than guessed at.
+    const code = pythonLines(
+      "class ProcessTupleOperator(UDFOperatorV2):",
+      "    def open(self):",
+      '        self.UiParameter(name="x", type="double")'
+    );
+
+    expect(service.parse(code)).toEqual([]);
+  });
+
+  it("should refuse to insert into a class that has no block body at all", () 
=> {
+    // A class header without its colon parses to a ClassDefinition with no 
Body,
+    // so there is no indented block to put the declaration in.
+    const code = "class ProcessTupleOperator(UDFOperatorV2)\n";
+
+    expect(() => service.computeParameterInsertion(code, "threshold", 
"double")).toThrow(UiUdfParametersEditError);
+    expect(() => service.computeParameterInsertion(code, "threshold", 
"double")).toThrow(
+      "The Python UDF class and open() need an indented block body to declare 
UiParameter values."
+    );
+  });
+
+  it("should synthesize open() below a class docstring, kept apart from the 
code after it", () => {
+    const code = pythonLines(
+      "class ProcessTupleOperator(UDFOperatorV2):",
+      '    """Doc."""',
+      "    def process(self, tuple_, port):",
+      "        yield tuple_"
+    );
+
+    const updated = insertParameter(service, code, "threshold");
+
+    // The docstring stays first, and the synthesized open() is fenced by a 
blank
+    // line on both sides: above it because it follows the docstring, below it
+    // because another statement follows.
+    expect(updated.split("\n")).toEqual([
+      "class ProcessTupleOperator(UDFOperatorV2):",
+      '    """Doc."""',
+      "",
+      "    def open(self) -> None:",
+      '        self.threshold = self.UiParameter(name="threshold", 
type=AttributeType.DOUBLE).value',
+      "",
+      "    def process(self, tuple_, port):",
+      "        yield tuple_",
+      "",
+    ]);
+    expect(service.parse(updated).map(p => 
p.attribute.attributeName)).toEqual(["threshold"]);
+  });
+
+  it("should append open() after a docstring that ends the file without a 
newline", () => {
+    // lineEnd() takes its `newline === -1` arm: there is no line break left to
+    // insert in front of, so the declaration goes at the very end of the file.
+    const code = 'class ProcessTupleOperator(UDFOperatorV2):\n    """Doc."""';
+
+    const updated = insertParameter(service, code, "threshold");
+
+    expect(updated.split("\n")).toEqual([
+      "class ProcessTupleOperator(UDFOperatorV2):",
+      '    """Doc."""',
+      "",
+      "    def open(self) -> None:",
+      '        self.threshold = self.UiParameter(name="threshold", 
type=AttributeType.DOUBLE).value',
+    ]);
+    expect(service.parse(updated).map(p => 
p.attribute.attributeName)).toEqual(["threshold"]);
+  });
 });
 
 function insertParameter(

Reply via email to