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 00fe327c2ec9d89920db2a11d3959b87a0562fe3
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sat Aug 15 04:23:38 2026 +0000

    test(frontend): drive the dataset detail template through the DOM (#7681)
    
    ### What changes were proposed in this PR?
    
    `dataset-detail.component.html` was at **92.0% of lines, 72.9% of
    branches and 42.9% of functions** behind a 129-test spec. This is
    **not** an instance of #7458 — the spec uses no
    `TestBed.overrideComponent`, so no attribution is lost. The gaps were
    real: handlers on child outputs, controls inside CDK overlays, the
    version-creator block, and the false legs of the header and settings
    ternaries.
    
    | | Before | After |
    |---|---|---|
    | lines | 312/339 (92.0%) | **339/339 (100%)** |
    | branches | 35/48 (72.9%) | **48/48 (100%)** |
    | functions | 15/35 (42.9%) | **35/35 (100%)** |
    
    Tests **129 -> 161**, all 32 new ones in one appended `describe` with
    its own `TestBed`, no override, real children and real overlays. No
    existing test or stub is touched.
    
    ### Verification — and a correction
    
    The first pass reported 25 mutations, all killed, zero survivors. **That
    did not hold.** Four reviewers were asked to *refute* the suite; between
    them they predicted 21 distinct surviving mutations, and **every one of
    the 21 did survive the 154-test suite.** 23 of the 24 findings are now
    fixed, and each repaired kill was re-proved with **only the new block
    selected** (`--filter "DatasetDetailComponent rendered template"`, 129
    pre-existing tests skipped), so no new test leans on a neighbour.
    
    Two failure modes are worth recording, because neither is visible in a
    coverage report:
    
    **Branches counted as covered and still unpinned.** The upload row's
    tooltip ternary read 2/2 covered, yet swapping its two results passed.
    Both legs rendered somewhere; nothing asserted *which*. The same shape
    hid an exchange of the two `nz-switch` models and an exchange of the
    view/like counters.
    
    **Degenerate fixtures.** A row with `totalTime` and
    `estimatedTimeRemaining` both `undefined` renders `["1s", "1s left"]`,
    because `formatTime` maps `undefined`, `0` and any `n <= 0` to `"1s"` —
    so swapping elapsed and remaining, a real user-visible bug, was
    undetectable. `formatSpeed(1024)` and `formatSpeed(40)` likewise both
    render `"0.0 MB/s"`. Those tests now use distinguishable values (`12s` /
    `1m30s left`, `5.0 MB/s`) and keep the degenerate case as a separate,
    honestly-named floor test.
    
    Also fixed: `toContain("2")` against a size of `2048` (passes on the raw
    number — now `toBe("2.00 KB")`), two `expect.any(Number)` arguments that
    left chunk-size and concurrency swappable, and an `isLogin` flag hidden
    behind `expect.anything()`.
    
    ### One mutation is deliberately left alive
    
    `html:401` `[(ngModel)]="selectedVersion"` -> `[ngModel]` survives, and
    that is correct rather than a gap: the same element carries
    `(ngModelChange)="onVersionSelected($event)"`, and `onVersionSelected`
    assigns `this.selectedVersion` itself (`ts:527`). Removing the two-way
    sugar leaves an observationally identical component. It is an equivalent
    mutant, settled by running it rather than by reading.
    
    ### A misdiagnosis worth flagging
    
    An earlier pass concluded that `[disabled]="isCreatingVersion"` on the
    version-name input was **inert** — that `NzInputDirective` swallowed the
    binding because `[(ngModel)]` supplies an `NgControl`. That was wrong,
    and the test settled it: `NgModel` routes `disabled` through
    `control.disable()`, which it defers to a **microtask**, so the DOM
    simply lags one turn behind `detectChanges()`. With the microtask hop
    the field really does lock. Both the flip and outright deletion of that
    binding now fail.
    
    ### Deliberately not included
    
    `html:585`'s `userHasWriteAccess() &&` is redundant — the enclosing
    `nz-collapse` at `html:452` is already gated on `userDatasetAccessLevel
    === "WRITE"`, which is exactly what that method returns. Reported, not
    tested.
    
    The read-only leg of the description editor's `[editable]` is
    unreachable from the template: the Settings tab itself sits behind
    `*ngIf="userHasWriteAccess()"`, so a reader never gets there.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7680
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/dataset-detail.component.spec.ts"
    ```
    
    ```
     Test Files  1 passed (1)
          Tests  161 passed (161)
    ```
    
    32 new on top of the existing 129. Coverage measured with `--coverage`
    on the same run. `yarn format:ci` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../dataset-detail.component.spec.ts               | 729 +++++++++++++++++++++
 1 file changed, 729 insertions(+)

diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
index c3e6f14d25..27f00cbfdf 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
@@ -17,11 +17,15 @@
  * under the License.
  */
 
+import { ApplicationRef, DebugElement } from "@angular/core";
 import { ComponentFixture, TestBed } from "@angular/core/testing";
 import { By } from "@angular/platform-browser";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
 import { ActivatedRoute, Router } from "@angular/router";
 import { of, Subject, throwError } from "rxjs";
 import { NzModalService } from "ng-zorro-antd/modal";
+import { NzResizableDirective } from "ng-zorro-antd/resizable";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
 import { MarkdownService } from "ngx-markdown";
 import {
   DatasetDetailComponent,
@@ -2052,3 +2056,728 @@ describe("DatasetDetailComponent behavior", () => {
     });
   });
 });
+
+/**
+ * The explorer's markup carries a lot of behaviour that never shows up in the
+ * component's own API: which icon labels a status tag, which contributor a row
+ * menu acts on, whether a toolbar button reaches the download service at all.
+ * Everything below drives the real template — real children, real overlays — 
and
+ * asserts on what is rendered, so a binding that quietly changes meaning 
fails.
+ */
+describe("DatasetDetailComponent rendered template", () => {
+  let fixture: ComponentFixture<DatasetDetailComponent>;
+  let component: DatasetDetailComponent;
+
+  type Stub = Record<string, ReturnType<typeof vi.fn>>;
+  let datasetService: Stub;
+  let downloadService: Stub;
+  let notificationService: Stub;
+  let modalService: Stub;
+  let hubService: Stub;
+
+  const OWNER = "[email protected]";
+
+  const aVersion = (over: Partial<DatasetVersion> = {}): DatasetVersion =>
+    ({ dvid: 11, did: 5, creatorUid: 9, name: "v1", ...over }) as 
DatasetVersion;
+
+  const makeFileItem = (name: string): FileUploadItem => ({
+    file: new File(["x"], name),
+    name,
+    description: "",
+    uploadProgress: 0,
+    isUploadingFlag: false,
+    restart: false,
+  });
+
+  beforeEach(() => {
+    TestBed.resetTestingModule();
+
+    datasetService = {
+      getDataset: vi.fn(() =>
+        of({
+          isOwner: true,
+          ownerEmail: OWNER,
+          accessPrivilege: "WRITE",
+          size: 0,
+          dataset: {
+            did: 5,
+            ownerUid: 9,
+            name: "ds",
+            isPublic: false,
+            isDownloadable: true,
+            description: "desc",
+          },
+        })
+      ),
+      retrieveDatasetVersionList: vi.fn(() => of([])),
+      retrieveDatasetLatestVersion: vi.fn(() => of(aVersion())),
+      retrieveDatasetVersionFileTree: vi.fn(() => of({ fileNodes: [], size: 
1024 })),
+      // The real file renderer is rendered here, and it fetches whatever file 
is on screen.
+      retrieveDatasetVersionSingleFile: vi.fn(() => of(new Blob(["a,b"], { 
type: "text/csv" }))),
+      getDatasetCoverUrl: vi.fn(() => of({ url: "http://cover"; })),
+      getDatasetDiff: vi.fn(() => of([])),
+      createDatasetVersion: vi.fn(() => of(aVersion())),
+      updateDatasetPublicity: vi.fn(() => of({})),
+      updateDatasetDownloadable: vi.fn(() => of({})),
+      updateDatasetCoverImage: vi.fn(() => of({})),
+      updateDatasetDescription: vi.fn(() => of({})),
+      updateDatasetContributors: vi.fn(() => of(undefined)),
+      updateDatasetName: vi.fn(() => of({})),
+      deleteDatasets: vi.fn(() => of({})),
+      deleteDatasetFile: vi.fn(() => of({})),
+      // Never completes, so an upload started from the template stays in 
flight
+      // and its row keeps rendering the "uploading" arm.
+      multipartUpload: vi.fn(() => new 
Subject<MultipartUploadProgress>().asObservable()),
+      finalizeMultipartUpload: vi.fn(() => of({})),
+    };
+    downloadService = {
+      downloadDatasetVersion: vi.fn(() => of(new Blob())),
+      downloadSingleFile: vi.fn(() => of(new Blob())),
+    };
+    notificationService = { success: vi.fn(), error: vi.fn(), info: vi.fn() };
+    modalService = { create: vi.fn(() => ({ afterClose: of(undefined) })) };
+    hubService = {
+      getCounts: vi.fn(() => of([{ counts: { like: 0 } }])),
+      postView: vi.fn(() => of(0)),
+      isLiked: vi.fn(() => of([{ isLiked: false }])),
+      postLike: vi.fn(() => of(true)),
+      postUnlike: vi.fn(() => of(true)),
+    };
+
+    TestBed.configureTestingModule({
+      imports: [DatasetDetailComponent, NoopAnimationsModule, 
...commonTestImports],
+      providers: [
+        { provide: ActivatedRoute, useValue: { params: of({ did: 5 }), data: 
of({}) } },
+        { provide: NzModalService, useValue: modalService },
+        { provide: DatasetService, useValue: datasetService },
+        { provide: NotificationService, useValue: notificationService },
+        { provide: DownloadService, useValue: downloadService },
+        { provide: UserService, useClass: StubUserService },
+        { provide: HubService, useValue: hubService },
+        { provide: AdminSettingsService, useValue: { getPublicSetting: 
vi.fn(() => of("3")) } },
+        { provide: MarkdownService, useValue: { parse: vi.fn(() => "") } },
+        ...commonTestProviders,
+      ],
+    });
+
+    fixture = TestBed.createComponent(DatasetDetailComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  afterEach(() => {
+    fixture?.destroy();
+    document.querySelectorAll(".cdk-overlay-container").forEach(container => 
(container.innerHTML = ""));
+  });
+
+  /** Applies state on top of what ngOnInit produced and renders it. */
+  const render = (state: Partial<DatasetDetailComponent> = {}): HTMLElement => 
{
+    Object.assign(component, state);
+    fixture.detectChanges();
+    return fixture.nativeElement as HTMLElement;
+  };
+
+  /** Asserts the element exists, so a stale selector fails as "not found". */
+  const q = <E extends Element>(root: ParentNode, selector: string): E => {
+    const el = root.querySelector(selector);
+    expect(el, `expected to find "${selector}"`).not.toBeNull();
+    return el as unknown as E;
+  };
+
+  /** Renders the fixture and the CDK overlays hanging off it. */
+  const flush = (): void => {
+    fixture.detectChanges();
+    TestBed.inject(ApplicationRef).tick();
+  };
+
+  const overlay = (): HTMLElement => q<HTMLElement>(document, 
".cdk-overlay-container");
+
+  // nz-dropdown audits its own visibility stream for 150ms before it opens an 
overlay.
+  const settleOverlay = async (): Promise<void> => {
+    await new Promise(resolve => setTimeout(resolve, 200));
+    flush();
+  };
+
+  const text = (el: Element | null | undefined): string => (el?.textContent ?? 
"").replace(/\s+/g, " ").trim();
+
+  // nz-tabs only instantiates the active tab, so a tab has to be opened before
+  // anything inside it exists to assert on.
+  const openTab = (title: string): HTMLElement => {
+    const tab = Array.from((fixture.nativeElement as 
HTMLElement).querySelectorAll<HTMLElement>(".ant-tabs-tab")).find(
+      el => (el.textContent ?? "").includes(title)
+    );
+    expect(tab, `expected a tab titled "${title}"`).toBeDefined();
+    tab!.click();
+    fixture.detectChanges();
+    return fixture.nativeElement as HTMLElement;
+  };
+
+  /** Expands the collapse panel whose header contains the given text. */
+  const openPanel = (header: string): void => {
+    const found = Array.from(
+      (fixture.nativeElement as 
HTMLElement).querySelectorAll<HTMLElement>(".ant-collapse-header")
+    ).find(h => (h.textContent ?? "").includes(header));
+    expect(found, `expected a collapse panel headed 
"${header}"`).toBeDefined();
+    found!.click();
+    fixture.detectChanges();
+  };
+
+  /** The toolbar button carrying the given nz-tooltip title. */
+  const byTooltip = (title: string): HTMLButtonElement | undefined =>
+    Array.from((fixture.nativeElement as 
HTMLElement).querySelectorAll<HTMLButtonElement>("button")).find(
+      b => b.getAttribute("nz-tooltip") === title
+    );
+
+  /** The nz-icon names rendered inside an element, read from their 
aria-labels. */
+  const iconNames = (root: ParentNode): string[] =>
+    Array.from(root.querySelectorAll<HTMLElement>(".anticon")).map(i => 
i.getAttribute("aria-label") ?? "");
+
+  describe("status tags", () => {
+    const tags = (): HTMLElement[] =>
+      Array.from((fixture.nativeElement as 
HTMLElement).querySelectorAll<HTMLElement>(".status-tag"));
+
+    it("labels a public, downloadable dataset with the globe and download 
icons", () => {
+      render({ datasetIsPublic: true, datasetIsDownloadable: true });
+
+      const [visibility, downloadable] = tags();
+      expect(text(visibility)).toBe("Public");
+      expect(iconNames(visibility)).toEqual(["global"]);
+      expect(visibility.classList).toContain("tag-public");
+
+      expect(text(downloadable)).toBe("Downloadable");
+      expect(iconNames(downloadable)).toEqual(["download"]);
+      expect(downloadable.classList).toContain("tag-downloadable");
+    });
+
+    it("labels a private, download-restricted dataset with the lock and stop 
icons", () => {
+      // The other leg of each tag: a visitor must be able to tell at a glance
+      // that the dataset is neither public nor downloadable.
+      render({ datasetIsPublic: false, datasetIsDownloadable: false });
+
+      const [visibility, downloadable] = tags();
+      expect(text(visibility)).toBe("Private");
+      expect(iconNames(visibility)).toEqual(["lock"]);
+      expect(visibility.classList).not.toContain("tag-public");
+
+      expect(text(downloadable)).toBe("Download restricted");
+      expect(iconNames(downloadable)).toEqual(["stop"]);
+      expect(downloadable.classList).not.toContain("tag-downloadable");
+    });
+
+    it("tells the view counter and the like counter apart", () => {
+      // The two counters are adjacent tags that differ only in which field 
they
+      // read, so each needs a count the other cannot produce.
+      render({ viewCount: 1500, likeCount: 3 });
+
+      const [, , views, likes] = tags();
+      expect(iconNames(views)).toEqual(["eye"]);
+      // Counts are abbreviated once they reach a thousand, not printed raw.
+      expect(text(views)).toBe("1.5k");
+
+      expect(iconNames(likes)).toEqual(["like"]);
+      expect(likes.classList).toContain("like-tag");
+      expect(text(likes)).toBe("3");
+    });
+  });
+
+  describe("settings hints", () => {
+    // Visibility and Downloadable are near-identical rows, so a hint or a 
switch
+    // is only meaningful next to the label it belongs to: reading them as one
+    // unordered pile would pass just as happily with the two rows exchanged.
+    const settingsRow = (el: HTMLElement, label: string): HTMLElement => {
+      const row = 
Array.from(el.querySelectorAll<HTMLElement>(".settings-name-row")).find(
+        r => text(r.querySelector("label")) === label
+      );
+      expect(row, `expected a settings row labelled "${label}"`).toBeDefined();
+      return row!;
+    };
+
+    const hintOf = (el: HTMLElement, label: string): string =>
+      text(q<HTMLElement>(settingsRow(el, label), ".settings-hint"));
+
+    const switchIsOn = (el: HTMLElement, label: string): boolean =>
+      q<HTMLElement>(settingsRow(el, label), "nz-switch 
button").classList.contains("ant-switch-checked");
+
+    it("spells out what public visibility and blocked downloads mean", () => {
+      render({ userDatasetAccessLevel: "WRITE", datasetIsPublic: true, 
datasetIsDownloadable: false });
+      const el = openTab("Settings");
+
+      expect(hintOf(el, "Visibility")).toBe("Public — anyone can view this 
dataset.");
+      expect(hintOf(el, "Downloadable")).toBe("Viewers can browse files but 
cannot download them.");
+      // The switch beside each hint has to report the same state the prose 
does.
+      expect(switchIsOn(el, "Visibility")).toBe(true);
+      expect(switchIsOn(el, "Downloadable")).toBe(false);
+    });
+
+    it("spells out what private visibility and permitted downloads mean", () 
=> {
+      render({ userDatasetAccessLevel: "WRITE", datasetIsPublic: false, 
datasetIsDownloadable: true });
+      const el = openTab("Settings");
+
+      expect(hintOf(el, "Visibility")).toBe("Private — only you and invited 
collaborators can see this dataset.");
+      expect(hintOf(el, "Downloadable")).toBe("Viewers can download this 
dataset.");
+      expect(switchIsOn(el, "Visibility")).toBe(false);
+      expect(switchIsOn(el, "Downloadable")).toBe(true);
+    });
+  });
+
+  describe("contributor row menu", () => {
+    const ada: Contributor = { name: "Ada", email: "[email protected]", affiliation: 
"Lab A", comments: "", creator: true };
+    const grace: Contributor = {
+      name: "Grace",
+      email: "[email protected]",
+      affiliation: "Lab B",
+      comments: "",
+      creator: false,
+    };
+
+    beforeEach(() => render({ did: 5, datasetContributors: [ada, grace], 
userDatasetAccessLevel: "WRITE" }));
+
+    /** Opens the actions dropdown on the card at `index` and returns its 
menu. */
+    const openRowMenu = async (index: number): Promise<HTMLElement> => {
+      const cards = (fixture.nativeElement as 
HTMLElement).querySelectorAll<HTMLElement>(".contributor-card");
+      expect(cards.length).toBeGreaterThan(index);
+      q<HTMLButtonElement>(cards[index], ".contributor-actions").click();
+      await settleOverlay();
+      // Each card declares its own menu template, so exactly one may be open.
+      const menus = 
overlay().querySelectorAll<HTMLElement>(".contributor-actions-menu");
+      expect(menus.length).toBe(1);
+      return menus[0];
+    };
+
+    const menuItem = (menu: HTMLElement, label: string): HTMLElement => {
+      const item = 
Array.from(menu.querySelectorAll<HTMLElement>("li")).find(li => text(li) === 
label);
+      expect(item, `expected a menu item labelled "${label}"`).toBeDefined();
+      return item!;
+    };
+
+    it("edits the contributor whose own row menu was used", async () => {
+      // The menu is declared inside the *ngFor, so its handlers have to close 
over
+      // that row's contributor rather than the first one in the list.
+      menuItem(await openRowMenu(1), "Edit").click();
+      flush();
+
+      expect(modalService.create).toHaveBeenCalledWith(
+        expect.objectContaining({ nzTitle: "Edit Contributor", nzData: grace })
+      );
+    });
+
+    it("deletes the contributor whose own row menu was used, once the deletion 
is confirmed", async () => {
+      menuItem(await openRowMenu(1), "Delete").click();
+      flush();
+
+      // The first click only asks; the row survives until the confirmation is 
accepted.
+      expect(text(q<HTMLElement>(overlay(), 
".ant-popover-inner"))).toContain('Delete contributor "Grace"?');
+      expect(datasetService.updateDatasetContributors).not.toHaveBeenCalled();
+
+      const confirm = 
Array.from(overlay().querySelectorAll<HTMLButtonElement>(".ant-popover-buttons 
button")).find(
+        b => text(b) === "Delete"
+      );
+      expect(confirm, "expected a Delete button in the 
confirmation").toBeDefined();
+      confirm!.click();
+      flush();
+
+      expect(datasetService.updateDatasetContributors).toHaveBeenCalledWith(5, 
[ada]);
+    });
+
+    it("adds a contributor from the keyboard on the add tile", () => {
+      const tile = q<HTMLElement>(fixture.nativeElement, 
".contributor-card-add");
+
+      tile.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: 
true }));
+      flush();
+      expect(modalService.create).toHaveBeenCalledTimes(1);
+      
expect(modalService.create).toHaveBeenLastCalledWith(expect.objectContaining({ 
nzTitle: "Add Contributor" }));
+
+      const space = new KeyboardEvent("keydown", { key: " ", bubbles: true, 
cancelable: true });
+      tile.dispatchEvent(space);
+      flush();
+      // Space activates the tile instead of scrolling the panel.
+      expect(space.defaultPrevented).toBe(true);
+      expect(modalService.create).toHaveBeenCalledTimes(2);
+    });
+  });
+
+  describe("file toolbar", () => {
+    beforeEach(() => {
+      render({ did: 5, selectedVersion: aVersion(), currentDisplayedFileName: 
"v1/a.csv", isLogin: true });
+      openTab("Versions & Files");
+    });
+
+    it("downloads the file that is on screen", () => {
+      // "On screen" has to mean the file the renderer beside the button 
fetched,
+      // not merely the field the test happened to set.
+      
expect(datasetService.retrieveDatasetVersionSingleFile).toHaveBeenCalledWith("v1/a.csv",
 true);
+      
expect(fixture.debugElement.query(By.css("texera-user-dataset-file-renderer")).componentInstance.filePath).toBe(
+        "v1/a.csv"
+      );
+
+      byTooltip("Download the file")!.click();
+
+      
expect(downloadService.downloadSingleFile).toHaveBeenCalledWith("v1/a.csv", 
true);
+    });
+
+    it("downloads the file that is on screen over the public endpoint for a 
non-owner", () => {
+      // The authenticated endpoint is the wrong one here: a visitor to 
somebody
+      // else's public dataset has no private access to fall back on.
+      render({ datasetIsPublic: true, datasetIsDownloadable: true, isOwner: 
false, userDatasetAccessLevel: "READ" });
+
+      const button = byTooltip("Download the file")!;
+      expect(button.disabled).toBe(false);
+      button.click();
+
+      
expect(downloadService.downloadSingleFile).toHaveBeenCalledWith("v1/a.csv", 
false);
+    });
+
+    it("keeps the owner of a public dataset on the authenticated endpoint", () 
=> {
+      // Publicity alone does not decide the endpoint: the owner still has 
private
+      // access, and the public route would hide their own unpublished changes.
+      render({ datasetIsPublic: true, isOwner: true });
+
+      byTooltip("Download the file")!.click();
+
+      
expect(downloadService.downloadSingleFile).toHaveBeenCalledWith("v1/a.csv", 
true);
+    });
+
+    it("maximizes the view from the toolbar and offers the way back", () => {
+      const el = fixture.nativeElement as HTMLElement;
+      expect(el.querySelector(".dataset-header")).not.toBeNull();
+
+      byTooltip("Maximize View")!.click();
+      fixture.detectChanges();
+
+      // Maximizing drops the dataset header so the file fills the pane.
+      expect(el.querySelector(".dataset-header")).toBeNull();
+      expect(byTooltip("Maximize View")).toBeUndefined();
+
+      byTooltip("Minimize View")!.click();
+      fixture.detectChanges();
+
+      expect(el.querySelector(".dataset-header")).not.toBeNull();
+      expect(byTooltip("Minimize View")).toBeUndefined();
+    });
+
+    it("applies a width the resize handle reports, between the bounds it 
declares", async () => {
+      const sider = fixture.debugElement.query(By.css("nz-sider"));
+      expect(sider.nativeElement.style.width).toBe("400px");
+
+      // The drag itself belongs to NzResizableDirective; what this component 
owns
+      // is the bounds it hands the directive and what it does with the 
reported
+      // width. Both have to be pinned, and in the right order — swapped bounds
+      // would let the handle collapse the sider past its minimum.
+      const resizable = sider.injector.get(NzResizableDirective);
+      expect(resizable.nzMinWidth).toBe(component.MIN_SIDER_WIDTH);
+      expect(resizable.nzMaxWidth).toBe(component.MAX_SIDER_WIDTH);
+      expect(resizable.nzMinWidth).toBeLessThan(resizable.nzMaxWidth as 
number);
+
+      sider.triggerEventHandler("nzResize", { width: 520 });
+      // The new width is applied on the next animation frame.
+      await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
+      fixture.detectChanges();
+
+      expect(sider.nativeElement.style.width).toBe("520px");
+    });
+  });
+
+  describe("version picker", () => {
+    const v1 = aVersion({ dvid: 11, name: "v1" });
+    const v2 = aVersion({ dvid: 12, name: "v2" });
+    const v3 = aVersion({ dvid: 13, name: "v3" });
+
+    beforeEach(() => {
+      render({ did: 5, datasetName: "ds", versions: [v1, v2, v3], 
selectedVersion: v1, isLogin: true });
+      openTab("Versions & Files");
+    });
+
+    it("offers every known version and loads the one that is picked", async () 
=> {
+      const select = fixture.debugElement.query(By.css("nz-select"));
+      /** Picks a version through the control and reports the name it then 
shows. */
+      const pick = async (version: DatasetVersion): Promise<string> => {
+        select.triggerEventHandler("ngModelChange", version);
+        fixture.detectChanges();
+        // ngModel pushes the new value into the control in a microtask.
+        await Promise.resolve();
+        fixture.detectChanges();
+        return text(q<HTMLElement>(fixture.nativeElement, 
".ant-select-selection-item"));
+      };
+      expect(text(q<HTMLElement>(fixture.nativeElement, 
".ant-select-selection-item"))).toBe("v1");
+
+      // The picker fans out over the whole list: every version has to be 
offered under
+      // its own name, not only the first one, which the control already shows.
+      expect([await pick(v2), await pick(v3), await pick(v1)]).toEqual(["v2", 
"v3", "v1"]);
+
+      // The third argument decides whether the tree is fetched over the
+      // authenticated or the anonymous endpoint, so it has to be the real 
flag.
+      
expect(datasetService.retrieveDatasetVersionFileTree).toHaveBeenCalledWith(5, 
12, true);
+      
expect(datasetService.retrieveDatasetVersionFileTree).toHaveBeenCalledWith(5, 
13, true);
+    });
+
+    it("loads a picked version over the anonymous endpoint when nobody is 
signed in", () => {
+      render({ isLogin: false });
+
+      
fixture.debugElement.query(By.css("nz-select")).triggerEventHandler("ngModelChange",
 v2);
+
+      
expect(datasetService.retrieveDatasetVersionFileTree).toHaveBeenCalledWith(5, 
12, false);
+    });
+
+    it("downloads the whole selected version as a zip", () => {
+      byTooltip("Download Dataset")!.click();
+
+      expect(downloadService.downloadDatasetVersion).toHaveBeenCalledWith(5, 
11, "ds", "v1");
+    });
+  });
+
+  describe("version file tree", () => {
+    const tree = (): DebugElement => 
fixture.debugElement.query(By.css("texera-user-dataset-version-filetree"));
+    // The first four segments (datasets/owner/dataset/version) are the prefix 
the
+    // relative path strips, so "nested" is the first segment the backend sees.
+    const leaf = (name: string): DatasetFileNode => ({
+      name,
+      type: "file",
+      parentDir: `/datasets/${OWNER}/ds/v1/nested`,
+      size: 2048,
+    });
+
+    beforeEach(() => {
+      render({ did: 5, selectedVersion: aVersion({ name: "v1" }) });
+      openTab("Versions & Files");
+    });
+
+    it("hands the tree the nodes of the version on screen", () => {
+      const nodes = [leaf("b.csv"), leaf("c.csv")];
+      render({ fileTreeNodeList: nodes });
+
+      expect(tree().componentInstance.fileTreeNodes).toEqual(nodes);
+    });
+
+    it("shows the file the tree selected", () => {
+      expect(text(q<HTMLElement>(fixture.nativeElement, 
".file-title-main"))).not.toContain("b.csv");
+
+      tree().triggerEventHandler("selectedTreeNode", leaf("b.csv"));
+      fixture.detectChanges();
+
+      // The heading is the full path — the copy-path button beside it copies
+      // exactly this string — not the bare file name or the relative path.
+      expect(text(q<HTMLElement>(fixture.nativeElement, 
".file-title-main"))).toBe(
+        `/datasets/${OWNER}/ds/v1/nested/b.csv`
+      );
+      // 2048 bytes reaches the reader as a human-readable size, not as a raw 
count.
+      expect(text(q<HTMLElement>(fixture.nativeElement, 
".file-size"))).toBe("2.00 KB");
+    });
+
+    it("deletes the file the tree asked to remove", () => {
+      tree().triggerEventHandler("deletedTreeNode", leaf("b.csv"));
+
+      expect(datasetService.deleteDatasetFile).toHaveBeenCalledWith(5, 
"nested/b.csv");
+    });
+
+    it("adopts the cover image the tree offered, qualified by the selected 
version", () => {
+      tree().triggerEventHandler("setCoverImage", "nested/b.png");
+
+      expect(datasetService.updateDatasetCoverImage).toHaveBeenCalledWith(5, 
"v1/nested/b.png");
+    });
+  });
+
+  describe("upload panel", () => {
+    beforeEach(() => render({ did: 5, userDatasetAccessLevel: "WRITE" }));
+
+    it("starts an upload for a file the uploader hands over", () => {
+      const el = openTab("Versions & Files");
+      const uploader = 
fixture.debugElement.query(By.css("texera-user-files-uploader"));
+
+      uploader.triggerEventHandler("uploadedFiles", [makeFileItem("new.csv")]);
+      fixture.detectChanges();
+
+      // The chunk size and the chunk concurrency are both plain numbers, so
+      // asserting their exact values is the only way to notice them exchanged:
+      // 10-byte chunks, or 52 million parallel requests, would look identical 
to
+      // expect.any(Number). Nobody is signed in here, so the component keeps 
its
+      // built-in defaults rather than the admin settings.
+      expect(component.chunkSizeMiB).toBe(50);
+      expect(component.maxConcurrentChunks).toBe(10);
+      expect(datasetService.multipartUpload).toHaveBeenCalledWith(
+        OWNER,
+        "ds",
+        "new.csv",
+        expect.anything(),
+        50 * 1024 * 1024,
+        10,
+        false
+      );
+      expect(text(el)).toContain("Uploading: 1 file(s)");
+    });
+
+    /** Renders the given in-flight tasks and expands the "Uploading" panel. */
+    const withTasks = (...tasks: Array<Record<string, unknown>>): HTMLElement 
=> {
+      const el = render({
+        uploadTasks: tasks.map(t => ({
+          percentage: 40,
+          status: "uploading",
+          uploadSpeed: 1024,
+          totalTime: 12,
+          estimatedTimeRemaining: 30,
+          ...t,
+        })) as never,
+      });
+      (component as unknown as { activeUploads: number }).activeUploads = 
tasks.length;
+      openTab("Versions & Files");
+      openPanel("Uploading:");
+      return el;
+    };
+
+    it("aborts the upload whose own row button was clicked", () => {
+      withTasks({ filePath: "first.csv" }, { filePath: "second.csv" });
+
+      const rows = 
fixture.debugElement.queryAll(By.css(".upload-progress-wrapper > div"));
+      expect(rows.length).toBe(2);
+      // Each row has to name its own task: identifying the row by position 
alone
+      // would not notice every row rendering the first task's name and status.
+      expect(rows.map(row => 
text(row.query(By.css(".progress-header")).nativeElement))).toEqual([
+        "uploading: first.csv",
+        "uploading: second.csv",
+      ]);
+
+      const abort = rows[1].query(By.css(".progress-header button"));
+      // A live upload is cancelled, not dismissed; the finished row below 
says "Close".
+      
expect(abort.injector.get(NzTooltipDirective).directiveTitle).toBe("Cancel the 
upload");
+
+      abort.nativeElement.click();
+      fixture.detectChanges();
+
+      expect(datasetService.finalizeMultipartUpload).toHaveBeenCalledTimes(1);
+      
expect(datasetService.finalizeMultipartUpload).toHaveBeenCalledWith(OWNER, 
"ds", "second.csv", true);
+    });
+
+    it("reports the elapsed time, the time remaining and the speed in their 
own slots", () => {
+      // Distinguishable timings, so the two spans cannot stand in for each 
other:
+      // showing 90s elapsed on a 12s-old upload is the defect this guards.
+      const el = withTasks({
+        filePath: "big.csv",
+        totalTime: 12,
+        estimatedTimeRemaining: 90,
+        uploadSpeed: 5 * 1024 * 1024,
+      });
+      const stats = q<HTMLElement>(el, ".upload-stats");
+
+      
expect(Array.from(stats.querySelectorAll(".fixed-width-time")).map(text)).toEqual(["12s",
 "1m30s left"]);
+      expect(text(q<HTMLElement>(stats, ".fixed-width-speed"))).toBe("5.0 
MB/s");
+    });
+
+    it("floors both live timings at one second while an upload reports none", 
() => {
+      const el = withTasks({ filePath: "big.csv", totalTime: undefined, 
estimatedTimeRemaining: undefined });
+
+      const times = Array.from(q<HTMLElement>(el, 
".upload-stats").querySelectorAll(".fixed-width-time")).map(text);
+      expect(times).toEqual(["1s", "1s left"]);
+    });
+
+    it("reports the total time of a finished upload", () => {
+      const el = withTasks({ filePath: "big.csv", status: "finished", 
totalTime: 75 });
+
+      expect(text(q<HTMLElement>(el, ".upload-stats"))).toContain("Upload 
time: 1m15s");
+      // A finished row is dismissed rather than cancelled.
+      const button = 
fixture.debugElement.query(By.css(".upload-progress-wrapper > div 
.progress-header button"));
+      
expect(button.injector.get(NzTooltipDirective).directiveTitle).toBe("Close");
+    });
+
+    it("floors the total of a finished upload that timed nothing", () => {
+      const el = withTasks({ filePath: "big.csv", status: "finished", 
totalTime: undefined });
+
+      expect(text(q<HTMLElement>(el, ".upload-stats"))).toContain("Upload 
time: 1s");
+    });
+  });
+
+  describe("version creator", () => {
+    /** Renders the creator, which only appears with staged changes to commit. 
*/
+    const withPendingChanges = (state: Partial<DatasetDetailComponent> = {}): 
HTMLElement => {
+      const el = render({ did: 5, userDatasetAccessLevel: "WRITE", 
userHasPendingChanges: true, ...state });
+      openTab("Versions & Files");
+      return el;
+    };
+
+    const typeName = (el: HTMLElement, value: string): HTMLInputElement => {
+      const input = q<HTMLInputElement>(el, ".version-input");
+      input.value = value;
+      input.dispatchEvent(new Event("input"));
+      fixture.detectChanges();
+      return input;
+    };
+
+    it("offers the creator only once there is something to commit", () => {
+      const el = render({ did: 5, userDatasetAccessLevel: "WRITE", 
userHasPendingChanges: false });
+      openTab("Versions & Files");
+      expect(el.querySelector(".version-creator")).toBeNull();
+
+      render({ userHasPendingChanges: true });
+
+      expect(el.querySelector(".version-creator")).not.toBeNull();
+      expect(text(q<HTMLElement>(el, 
".create-dataset-version-button"))).toBe("Submit");
+    });
+
+    it("creates a version named by the creator's own input", () => {
+      const el = withPendingChanges();
+
+      typeName(el, "second cut");
+      q<HTMLButtonElement>(el, ".create-dataset-version-button").click();
+
+      expect(datasetService.createDatasetVersion).toHaveBeenCalledWith(5, 
"second cut");
+    });
+
+    it("submits the version straight from the name field with Enter", () => {
+      const el = withPendingChanges();
+
+      typeName(el, "from the keyboard").dispatchEvent(new 
KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+
+      expect(datasetService.createDatasetVersion).toHaveBeenCalledWith(5, 
"from the keyboard");
+    });
+
+    it("spins the submit button and locks the name field while a version is 
being created", async () => {
+      const el = withPendingChanges();
+      expect(q<HTMLButtonElement>(el, 
".create-dataset-version-button").classList).not.toContain("ant-btn-loading");
+      expect(q<HTMLInputElement>(el, ".version-input").disabled).toBe(false);
+
+      render({ isCreatingVersion: true });
+      // NgModel routes the input's `disabled` binding through 
control.disable(),
+      // which it defers to a microtask, so the DOM lags the render by one 
turn.
+      await Promise.resolve();
+      fixture.detectChanges();
+
+      expect(q<HTMLButtonElement>(el, 
".create-dataset-version-button").classList).toContain("ant-btn-loading");
+      // Renaming a version mid-creation would be applied to nothing, so the
+      // field is locked for as long as the request is in flight.
+      expect(q<HTMLInputElement>(el, ".version-input").disabled).toBe(true);
+    });
+  });
+
+  describe("settings tab", () => {
+    it("persists a description edited on the Settings tab", () => {
+      render({ did: 5, userDatasetAccessLevel: "WRITE", datasetDescription: 
"old" });
+      openTab("Settings");
+
+      const editor = fixture.debugElement.query(By.css(".settings-field 
texera-markdown-description"));
+      // The editor is what the writer types into, so it has to arrive holding 
the
+      // description that is live and unlocked for editing. (The tab itself is
+      // behind *ngIf="userHasWriteAccess()", so a reader never gets this far 
and
+      // the read-only leg of [editable] is unreachable from here.)
+      expect(editor.componentInstance.description).toBe("old");
+      expect(editor.componentInstance.editable).toBe(true);
+
+      editor.triggerEventHandler("descriptionChange", "brand new");
+
+      expect(datasetService.updateDatasetDescription).toHaveBeenCalledWith(5, 
"brand new");
+    });
+
+    it("deletes the dataset only once the confirmation is accepted", () => {
+      const el = render({ did: 5, datasetName: "ds", userDatasetAccessLevel: 
"WRITE", isOwner: true });
+      openTab("Settings");
+      const navigate = vi.spyOn(TestBed.inject(Router), 
"navigate").mockResolvedValue(true);
+
+      q<HTMLButtonElement>(el, 'button[title="Delete"]').click();
+      flush();
+      expect(datasetService.deleteDatasets).not.toHaveBeenCalled();
+
+      q<HTMLButtonElement>(overlay(), ".ant-popover-buttons 
button.ant-btn-primary").click();
+      flush();
+
+      expect(datasetService.deleteDatasets).toHaveBeenCalledWith(5);
+      expect(navigate).toHaveBeenCalledWith([USER_DATASET]);
+    });
+  });
+});

Reply via email to