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]); + }); + }); +});
