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-8290-0298a3bbe49b644d062a5e3b3dd56dd0e399cc84 in repository https://gitbox.apache.org/repos/asf/texera.git
commit f13f175c801ff62178ab1149f07b93b34509fa45 Author: Xinyuan Lin <[email protected]> AuthorDate: Mon Aug 31 15:35:16 2026 +0000 test(frontend): cover the model-detail page, admin settings and heatmap scoring (#8290) ### What changes were proposed in this PR? Four existing frontend specs extended. **+63 fully-covered lines and +15 branch arms** — 916/978 → 979/978 across the five files, four of which reach 100%. | File | Codecov fully-covered | Branch arms | |---|---|---| | `model-detail.component.ts` | 197/228 → **227/228** | 90/100 → **99/100** | | `model-detail.component.html` | 203/221 → **221/221** | 26/30 → **30/30** | | `admin-settings.component.html` | 204/214 → **214/214** | 34/34 | | `heatmap-scoring.ts` | 50/54 → **54/54** | 40/42 → **42/42** | | `dataset-detail.component.html` | 262/263 → **263/263** | 32/32 | `model-detail.component.ts` goes from 31 missed lines to 1. The plain line-hit metric moves +50 against Codecov's +63, because thirteen of the gained lines were already executed and flip only by completing a branch arm — the two numbers are not interchangeable and both are given. This is newly-landed Models-page code: `model-detail.component.ts` had **grown by 13 missed lines** since the previous coverage scan, and `heatmap-scoring.ts` is a new file. `model-detail.component.spec.ts` goes 48 → 67 tests. ### Angular templates are real, countable coverage Worth stating because it is easy to assume otherwise: `.html` templates appear in `lcov.info` as their **own `SF:` records** — v8 coverage remapped through the AOT source map — and each "line" is a generated listener or creation block. **28 of the 63 lines here are in templates.** A single-spec run shows ten such records. ### Three DOM-query traps, each hit and solved - **`model-detail.component.html` has two `<texera-markdown-description>` instances** (Model Card and Settings), and `nz-tabs` keeps already-activated panes alive. `query(By.directive(MarkdownDescriptionComponent))` therefore returns the *non-editable* one and a `descriptionChange` emit goes nowhere. The tests select on `.editable` rather than taking the first match. Same class of trap for `nz-select` — two on Settings plus one on Versions & Files — so those are indexed. - **`nz-tooltip` needs an animations module.** Opening one instantiates an overlay host carrying `[@zoomBigMotion]`, which throws without it. The new `admin-settings` describe has its own TestBed with `NoopAnimationsModule`. - **Tooltip content renders into the CDK overlay on `document.body`**, not `fixture.nativeElement`, so those assertions read from `document.body`. That describe also drains the `assets/` requests `nz-icon` lazily fetches before `http.verify()`. `onSideResize` assigns inside a `requestAnimationFrame` callback, so its test awaits a frame rather than using `fakeAsync` — jsdom runs rAF callbacks FIFO, so the component's fires first. ### Verification **49 mutants, zero survivors** — 36 re-derived from scratch plus 13 more. Every kill is credited to the exact test that failed. The only two multi-killers are disclosed, and each is second-killed by another *new* test rather than by pre-existing collateral. Three mutants survive and are reported plainly as genuinely equivalent, with reasoning, rather than papered over. Measured with the **full** frontend suite in one command — no name filter and no `--include` at all — so there is no filter-attribution risk. `LF` is unchanged on all five files, confirming zero production drift. The lcov landed at `frontend/coverage/gui/lcov.info`; both metrics were parsed with a script applying Codecov's rule directly. **The repair pass moved coverage by exactly zero** on every one of the five files, verified as a separate builder-final → repair-final parse. The builder's +63 stands and reproduced number for number, including the one file it did not fully close. The repair bought mutation strength only, and that is stated rather than sold as coverage. ### Deliberately not included `model-detail.component.ts:245` remains partial — an unreachable arm. `dataset-detail.component.html` contributes 1 line and was nearly dropped: its spec has three top-level describes with different helper names and render helpers, so appending at the end of the file does not compile. `yarn format:ci` passes — the inserted blocks initially failed `prettier --check`, which is a CI step, so that was fixed rather than left. `frontend/junit.xml` is regenerated by every run, is not gitignored, and is not committed. No production file is touched. ### Any related issues, documentation, discussions? Closes #8289 ### How was this PR tested? ``` npx ng test --watch=false --include="**/model-detail.component.spec.ts" --include="**/admin-settings.component.spec.ts" --include="**/heatmap-scoring.spec.ts" --include="**/dataset-detail.component.spec.ts" ``` ``` Test Files 4 passed (4) ``` Re-run after rebasing onto current `main`. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --------- Signed-off-by: Xinyuan Lin <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../settings/admin-settings.component.spec.ts | 72 ++++ .../dataset-detail.component.spec.ts | 14 + .../model-detail.component.spec.ts | 455 +++++++++++++++++++++ .../service/heatmap/heatmap-scoring.spec.ts | 19 + 4 files changed, 560 insertions(+) diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts index 89bc450512..b275285c4d 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts @@ -24,6 +24,8 @@ import { NzCardModule } from "ng-zorro-antd/card"; import { NzMessageService } from "ng-zorro-antd/message"; import { NotificationService } from "../../../../common/service/notification/notification.service"; import { By } from "@angular/platform-browser"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; describe("AdminSettingsComponent", () => { let component: AdminSettingsComponent; @@ -809,3 +811,73 @@ describe("AdminSettingsComponent wiring", () => { }); }); }); + +/** + * The part-count guidance behind each upload card's info icon. It is an ng-template, so nothing + * of it exists until a tooltip actually opens — the suites above never open one, and the whole + * block (including the AWS link's rel) went unrendered. + * + * Its own TestBed: opening an nz-tooltip instantiates an overlay component whose host carries + * [@zoomBigMotion], which throws without an animations module. + */ +describe("AdminSettingsComponent upload guidance tooltip", () => { + let component: AdminSettingsComponent; + let fixture: ComponentFixture<AdminSettingsComponent>; + let http: HttpTestingController; + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [AdminSettingsComponent, HttpClientTestingModule, NzCardModule, NoopAnimationsModule], + }).compileComponents(); + + http = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(AdminSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + http.expectOne("/api/config/settings").flush({}); + fixture.detectChanges(); + }); + + afterEach(() => { + // nz-icon lazily fetches its SVG assets; drain those so verify() only asserts on + // requests this suite actually expects. + http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("")); + http.verify(); + fixture.destroy(); + }); + + /** One info icon per upload card, in the order the cards render. */ + function infoTooltips() { + return fixture.debugElement.queryAll(By.directive(NzTooltipDirective)); + } + + it("spells out the part-count guidance behind an upload card's info icon", () => { + // One info icon per upload card, in the order the cards render. + expect(infoTooltips().length).toBe(component.uploadGroups.length); + + infoTooltips()[0].injector.get(NzTooltipDirective).show(); + fixture.detectChanges(); + + // The tooltip renders into the CDK overlay on document.body, not into the fixture. + const text = document.body.textContent ?? ""; + expect(text).toContain("Configuration Guidelines"); + expect(text).toContain("Concurrent parts"); + // The 10,000-part ceiling is the reason the guidance exists at all: it is the S3 limit + // the operator silently crosses by leaving the part size small on a large file. + expect(text).toContain("10,000 parts"); + expect(text).toContain("auto-adjusted"); + }); + + it("opens the AWS limits reference in a new tab without leaking the admin page", () => { + infoTooltips()[0].injector.get(NzTooltipDirective).show(); + fixture.detectChanges(); + + const link = document.body.querySelector<HTMLAnchorElement>('a[target="_blank"]'); + expect(link).not.toBeNull(); + expect(link!.getAttribute("href")).toContain("docs.aws.amazon.com"); + // target="_blank" without noopener hands the opened page a window.opener handle back + // into the admin settings page. + expect(link!.getAttribute("rel")).toBe("noopener noreferrer"); + }); +}); 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 aeaa839258..b91db9c8fe 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 @@ -285,6 +285,20 @@ describe("DatasetDetailComponent rendered explorer", () => { .componentInstance as VersionUploaderComponent; expect(panel.pendingChangesCount).toBe(1); }); + + it("reloads the version list once the panel reports a new version", () => { + render(); // the panel lives in the Versions & Files tab, which nz-tabs renders lazily + const datasetService = TestBed.inject(DatasetService) as unknown as Record<string, ReturnType<typeof vi.fn>>; + const before = datasetService["retrieveDatasetVersionList"].mock.calls.length; + const panel = fixture.debugElement.query(By.directive(VersionUploaderComponent)) + .componentInstance as VersionUploaderComponent; + + panel.versionCreated.emit(); + + // The panel owns the version flow but not the page's state: without this binding the new + // version is committed and never appears in the picker until a reload. + expect(datasetService["retrieveDatasetVersionList"].mock.calls.length).toBe(before + 1); + }); }); describe("contributor cards", () => { diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts index 93a89bfd0c..6555ccb75a 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts @@ -23,6 +23,8 @@ import { ActivatedRoute } from "@angular/router"; import { of, throwError } from "rxjs"; import { MarkdownService } from "ngx-markdown"; import { NzModalService } from "ng-zorro-antd/modal"; +import { NgModel } from "@angular/forms"; +import { NzResizableDirective } from "ng-zorro-antd/resizable"; import { By } from "@angular/platform-browser"; import { commonTestImports, commonTestProviders } from "../../../../../common/testing/test-utils"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; @@ -35,8 +37,10 @@ import { MultipartUploadService } from "../../../../service/user/file-resource/m import { StagedFileService } from "../../../../service/user/file-resource/staged-file.service"; import { MODEL_FILE_RESOURCE_ENDPOINT } from "../../../../service/user/file-resource/file-resource-endpoint"; import { VersionUploaderComponent } from "../../version-uploader/version-uploader.component"; +import { MarkdownDescriptionComponent } from "../../markdown-description/markdown-description.component"; import { DatasetFileNode } from "../../../../../common/type/datasetVersionFileTree"; import { ModelVersion } from "../../../../../common/type/model"; +import { Role, User } from "../../../../../common/type/user"; import { ModelDetailComponent } from "./model-detail.component"; const MID = 5; @@ -175,6 +179,9 @@ describe("ModelDetailComponent", () => { return el as unknown as E; }; + // The sider coalesces resize events into one write per animation frame. + const nextFrame = (): Promise<void> => new Promise<void>(resolve => requestAnimationFrame(() => resolve())); + // ─── loading the model ────────────────────────────────────────────────────── it("reads the mid off the route as a number and loads the model", () => { @@ -182,6 +189,9 @@ describe("ModelDetailComponent", () => { expect(component.mid).toBe(MID); expect(modelService["getModel"]).toHaveBeenCalledWith(MID, true); + // Every read here picks its endpoint off isLogin; routing a signed-in user's version + // listing through the anonymous one returns nothing at all for a private model. + expect(modelService["retrieveModelVersionList"]).toHaveBeenCalledWith(MID, true); expect(component.modelName).toBe("resnet-50"); expect(component.modelDescription).toBe("a description"); expect(component.modelFramework).toBe("pytorch"); @@ -620,6 +630,9 @@ describe("ModelDetailComponent", () => { // holding the old name would 404 on every file until the page was reloaded. expect(modelService["retrieveModelVersionFileTree"]).toHaveBeenCalledWith(MID, 1, true); expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-101/v1/model.pt`); + // Once, not twice: the newest version is the one on screen, so the Model Card is filled + // in from this very response instead of a second identical request. + expect(modelService["retrieveModelVersionFileTree"]).toHaveBeenCalledTimes(1); }); it("lists the newest version's objects once on load, not once per consumer", () => { @@ -797,4 +810,446 @@ describe("ModelDetailComponent", () => { expect(component.isMaximized).toBe(true); expect(fixture.nativeElement.querySelector(".model-header")).toBeNull(); }); + + // ─── the signed-in user ───────────────────────────────────────────────────── + + it("tracks the signed-in user as the session changes under it", () => { + // The page is reachable while signed out (a public model), and every service call + // this component makes picks its endpoint off isLogin. + create(); + const users = TestBed.inject(UserService) as unknown as StubUserService; + expect(component.isLogin).toBe(true); + + users.user = undefined; + users.userChangeSubject.next(undefined); + + expect(component.isLogin).toBe(false); + expect(component.currentUid).toBeUndefined(); + + const signedIn = { uid: 42, name: "n", email: "e", role: Role.REGULAR } as User; + users.user = signedIn; + users.userChangeSubject.next(signedIn); + + expect(component.isLogin).toBe(true); + expect(component.currentUid).toBe(42); + }); + + // ─── the resizable sider ──────────────────────────────────────────────────── + + it("stores the dragged sider width on the next animation frame", async () => { + create(); + expect(component.siderWidth).toBe(400); + + // A drag emits continuously; the component coalesces to one write per frame. + component.onSideResize({ width: 250, height: 999 }); + + // The write is deferred, not immediate — that deferral is the whole point of the + // requestAnimationFrame hop, and a synchronous assignment would land here. + expect(component.siderWidth).toBe(400); + await nextFrame(); + + expect(component.siderWidth).toBe(250); + }); + + // ─── guards against an absent model id ────────────────────────────────────── + + it("fetches nothing without a model id", () => { + create(); + modelService["getModel"].mockClear(); + modelService["retrieveModelVersionList"].mockClear(); + component.mid = undefined; + + component.retrieveModelInfo(); + component.retrieveModelVersionList(); + + // Without the guards these would request /api/model/undefined. + expect(modelService["getModel"]).not.toHaveBeenCalled(); + expect(modelService["retrieveModelVersionList"]).not.toHaveBeenCalled(); + }); + + it("saves nothing and changes nothing on screen without a model id", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + modelService["updateModelDescription"] = vi.fn(() => of({})); + modelService["updateModelFramework"] = vi.fn(() => of({})); + modelService["updateModelFormat"] = vi.fn(() => of({})); + create(); + component.mid = undefined; + component.editedModelName = "renamed"; + + component.onSaveModelName(); + component.onModelDescriptionChange("changed"); + component.onFrameworkChange("tensorflow"); + component.onFormatChange("onnx"); + + expect(modelService["updateModelName"]).not.toHaveBeenCalled(); + expect(modelService["updateModelDescription"]).not.toHaveBeenCalled(); + expect(modelService["updateModelFramework"]).not.toHaveBeenCalled(); + expect(modelService["updateModelFormat"]).not.toHaveBeenCalled(); + // The description, framework and format writes are all optimistic, so a missing guard + // would also leave the page claiming a value that was never persisted. + expect(component.modelName).toBe("resnet-50"); + expect(component.modelDescription).toBe("a description"); + expect(component.modelFramework).toBe("pytorch"); + expect(component.modelFormat).toBe("torchscript"); + }); + + // ─── timestamps that are not timestamps ───────────────────────────────────── + + it("leaves the creation time blank when the model carries no usable timestamp", () => { + modelService["getModel"] = vi.fn(() => of(dashboardModel({ model: { creationTime: undefined } }))); + create(); + + expect(component.modelCreationTime).toBe(""); + expect(component.modelCreationTimeTooltip).toBe(""); + + // A timestamp that arrives as a string is not a timestamp either: formatting it would + // print a date and a time zone the backend never sent. + modelService["getModel"] = vi.fn(() => of(dashboardModel({ model: { creationTime: "2023-11-03T00:00:00Z" } }))); + create(); + + expect(component.modelCreationTime).toBe(""); + expect(component.modelCreationTimeTooltip).toBe(""); + }); + + it("leaves a version's creation time blank, and its row off the sider, without a timestamp", () => { + modelService["retrieveModelVersionList"] = vi.fn(() => + of([{ ...aVersion(1, "v1"), creationTime: undefined as unknown as number }]) + ); + create(); + + expect(component.latestVersionCreationTime).toBe(""); + expect(component.selectedVersionCreationTime).toBe(""); + // An em dash here would render "Created at: —"; the absent-timestamp row is meant to + // disappear instead, and the Model Card supplies its own dash. + expect(openTab("Versions & Files").querySelector(".version-date")).toBeNull(); + + // A timestamp that arrives as a string is not a timestamp either, and this guard tests the + // type rather than mere presence: a not-undefined check would let the string through and + // print a date the backend never sent. + modelService["retrieveModelVersionList"] = vi.fn(() => + of([{ ...aVersion(1, "v1"), creationTime: "2023-11-03T00:00:00Z" as unknown as number }]) + ); + create(); + + expect(component.latestVersionCreationTime).toBe(""); + expect(component.selectedVersionCreationTime).toBe(""); + expect(openTab("Versions & Files").querySelector(".version-date")).toBeNull(); + }); + + // ─── failures reaching the user ───────────────────────────────────────────── + + it("reports a file-tree failure for the version being opened", () => { + modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")])); + modelService["retrieveModelVersionFileTree"] = vi.fn(() => throwError(() => new Error("version tree gone"))); + create(); + + expect(notificationService["error"]).toHaveBeenCalledWith("version tree gone"); + }); + + it("reports a rejected rename and keeps the old name on screen", () => { + modelService["updateModelName"] = vi.fn(() => throwError(() => new Error("name already taken"))); + create(); + component.editedModelName = "resnet-101"; + + component.onSaveModelName(); + + // The rename is not optimistic: the header must not claim a name the server refused. + expect(component.modelName).toBe("resnet-50"); + expect(notificationService["error"]).toHaveBeenCalledWith("name already taken"); + }); + + it("reports a failure refreshing the Model Card after a rename", () => { + const versions = [aVersion(2, "v2"), aVersion(1, "v1")]; + modelService["updateModelName"] = vi.fn(() => of({})); + modelService["retrieveModelVersionList"] = vi.fn(() => of(versions)); + create(); + component.onVersionSelected(versions[1]); + + // The browsed version still resolves; only the newest one — fetched for the card — does not. + modelService["retrieveModelVersionFileTree"] = vi.fn((_mid: number, mvid: number) => + mvid === 2 ? throwError(() => new Error("latest tree gone")) : of({ fileNodes: [], size: 0 }) + ); + notificationService["error"].mockClear(); + component.editedModelName = "resnet-101"; + + component.onSaveModelName(); + + expect(notificationService["error"]).toHaveBeenCalledWith("latest tree gone"); + }); + + it("clears the Model Card facts when no version is left to describe", () => { + const versions = [aVersion(2, "v2"), aVersion(1, "v1")]; + modelService["updateModelName"] = vi.fn(() => of({})); + modelService["retrieveModelVersionList"] = vi.fn(() => of(versions)); + modelService["retrieveModelVersionFileTree"] = vi.fn(() => + of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v2`)], size: 512 }) + ); + create(); + component.onVersionSelected(versions[1]); + expect(component.latestVersionSize).toBe(512); + + // Every version was deleted from another tab, so the refresh finds nothing to describe. + component.versions = []; + component.editedModelName = "resnet-101"; + component.onSaveModelName(); + + // Stale facts here would have the card describing a version that no longer exists. + expect(component.latestVersionCreationTime).toBe(""); + expect(component.latestVersionFileName).toBe(""); + expect(component.latestVersionSize).toBeUndefined(); + }); + + it("falls back to the version's first file when the path being reopened is gone", () => { + modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")])); + modelService["retrieveModelVersionFileTree"] = vi.fn(() => + of({ + fileNodes: [ + aFile("first.txt", `/model/${OWNER}/resnet-50/v1`), + { + name: "weights", + type: "directory" as const, + parentDir: `/model/${OWNER}/resnet-50/v1`, + children: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1/weights`)], + }, + ], + size: 8, + }) + ); + create(); + + component.onVersionSelected(component.versions[0], "weights/deleted.pt"); + + // A miss has to read as a miss: returning whatever the walk last looked at would open + // the nested file instead of the version's first. + expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v1/first.txt`); + }); + + it("treats a cleared description as an empty one", () => { + modelService["updateModelDescription"] = vi.fn(() => of({})); + create(); + + component.onModelDescriptionChange(undefined as unknown as string); + + // An undefined body would be written into the column verbatim, and the + // did-anything-change comparison below would then never settle. + expect(modelService["updateModelDescription"]).toHaveBeenCalledWith(MID, ""); + expect(component.modelDescription).toBe(""); + }); + + it("rolls back a rejected framework, and confirms an accepted format", () => { + modelService["updateModelFramework"] = vi.fn(() => throwError(() => new Error("unknown framework"))); + modelService["updateModelFormat"] = vi.fn(() => of({})); + create(); + + component.onFrameworkChange("tensorflow"); + + // The select is written optimistically, so a rejected change has to be put back or the + // page shows a framework the model does not have. + expect(component.modelFramework).toBe("pytorch"); + expect(notificationService["error"]).toHaveBeenCalledWith("unknown framework"); + + // An accepted framework is confirmed under its own label. The two messages sit twenty + // lines apart and differ only in the noun, so each needs pinning at its own site — a + // simultaneous swap of both would otherwise pass for a single one-sided regression. + modelService["updateModelFramework"] = vi.fn(() => of({})); + component.onFrameworkChange("tensorflow"); + + expect(component.modelFramework).toBe("tensorflow"); + expect(notificationService["success"]).toHaveBeenCalledWith("Framework set to 'tensorflow'"); + + component.onFormatChange("onnx"); + + expect(component.modelFormat).toBe("onnx"); + expect(notificationService["success"]).toHaveBeenCalledWith("Format set to 'onnx'"); + }); + + it("skips a framework or format change that is already the current value", () => { + modelService["updateModelFramework"] = vi.fn(() => of({})); + modelService["updateModelFormat"] = vi.fn(() => of({})); + create(); + + component.onFrameworkChange("pytorch"); + component.onFormatChange("torchscript"); + + expect(modelService["updateModelFramework"]).not.toHaveBeenCalled(); + expect(modelService["updateModelFormat"]).not.toHaveBeenCalled(); + }); + + // ─── the template's own listeners, driven from the DOM ────────────────────── + // + // Every test above calls a handler on the instance, which cannot tell whether the + // template is wired to it at all. These drive the real controls instead. + + const oneVersionWithOneFile = (): void => { + modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")])); + modelService["retrieveModelVersionFileTree"] = vi.fn(() => + of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)], size: 8 }) + ); + }; + + /** The toolbar buttons carry no classes, only their tooltip text. */ + const byTooltip = (title: string): HTMLButtonElement => { + const found = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>("button")).find( + button => button.getAttribute("nz-tooltip") === title + ); + expect(found, `expected a button tooltipped "${title}"`).toBeDefined(); + return found!; + }; + + it("wires each preview toolbar button to its own handler", () => { + oneVersionWithOneFile(); + create(); + const root = openTab("Versions & Files"); + const originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + + q<HTMLButtonElement>(root, ".copy-path-btn").click(); + expect(writeText).toHaveBeenCalledWith(`/model/${OWNER}/resnet-50/v1/model.pt`); + + if (originalClipboardDescriptor) { + Object.defineProperty(navigator, "clipboard", originalClipboardDescriptor); + } else { + delete (navigator as any).clipboard; + } + + byTooltip("Download the file").click(); + expect(downloadService["downloadModelSingleFile"]).toHaveBeenCalledWith( + `/model/${OWNER}/resnet-50/v1/model.pt`, + true + ); + + byTooltip("Download this version as ZIP").click(); + expect(downloadService["downloadModelVersion"]).toHaveBeenCalledWith(MID, 1, "resnet-50", "v1"); + + // Maximize and Minimize are two buttons behind opposite *ngIfs, one of which is on screen + // at a time; swapping them would leave the preview with no way back. + byTooltip("Maximize View").click(); + fixture.detectChanges(); + expect(component.isMaximized).toBe(true); + byTooltip("Minimize View").click(); + fixture.detectChanges(); + expect(component.isMaximized).toBe(false); + + byTooltip("Hide the right bar").click(); + fixture.detectChanges(); + expect(component.isRightBarCollapsed).toBe(true); + expect((fixture.nativeElement as HTMLElement).querySelector("nz-sider")).toBeNull(); + + byTooltip("Show Tree").click(); + fixture.detectChanges(); + expect(component.isRightBarCollapsed).toBe(false); + expect((fixture.nativeElement as HTMLElement).querySelector("nz-sider")).not.toBeNull(); + + // Both download controls sit behind the same [disabled] expression. Clicking them above + // only proves what they call; a signed-out viewer, or a model with downloads switched + // off, must find them dead rather than firing requests the server will refuse. + render({ isOwner: false, modelIsDownloadable: false, modelIsPublic: true }); + expect(byTooltip("Download the file").disabled).toBe(true); + expect(q<HTMLButtonElement>(fixture.nativeElement, ".spaced-button").disabled).toBe(true); + }); + + it("wires the sider's resize handle to the width the page keeps", async () => { + oneVersionWithOneFile(); + create(); + openTab("Versions & Files"); + + const sider = fixture.debugElement.query(By.directive(NzResizableDirective)); + expect(sider).not.toBeNull(); + sider.injector.get(NzResizableDirective).nzResize.emit({ width: 275, height: 0 }); + await nextFrame(); + + expect(component.siderWidth).toBe(275); + }); + + it("wires the version picker to both the selection and the file tree", () => { + const versions = [aVersion(2, "v2"), aVersion(1, "v1")]; + modelService["retrieveModelVersionList"] = vi.fn(() => of(versions)); + modelService["retrieveModelVersionFileTree"] = vi.fn((_mid: number, mvid: number) => + of({ fileNodes: [aFile(`v${mvid}.pt`, `/model/${OWNER}/resnet-50/v${mvid}`)], size: mvid }) + ); + create(); + const root = openTab("Versions & Files"); + + // One select on this tab, and its two-way write-back and its change handler are separate + // bindings: without the write-back the picker snaps back to the version it was showing. + expect(root.querySelectorAll("nz-select").length).toBe(1); + fixture.debugElement.query(By.css("nz-select")).injector.get(NgModel).viewToModelUpdate(versions[1]); + fixture.detectChanges(); + + expect(component.selectedVersion).toBe(versions[1]); + expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v1/v1.pt`); + }); + + it("wires each Settings control to its own field", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + modelService["updateModelDescription"] = vi.fn(() => of({})); + modelService["updateModelFramework"] = vi.fn(() => of({})); + modelService["updateModelFormat"] = vi.fn(() => of({})); + create(); + const root = openTab("Settings"); + + const nameInput = q<HTMLInputElement>(root, ".settings-name-controls input"); + nameInput.value = "resnet-101"; + nameInput.dispatchEvent(new Event("input")); + fixture.detectChanges(); + expect(component.editedModelName).toBe("resnet-101"); + + const save = Array.from(root.querySelectorAll<HTMLButtonElement>("button")).find( + button => (button.textContent ?? "").trim() === "Save" + ); + expect(save, "expected a Save button").toBeDefined(); + save!.click(); + expect(modelService["updateModelName"]).toHaveBeenCalledWith(MID, "resnet-101"); + + // Two markdown editors exist at once — the Model Card's read-only one is already + // instantiated on the default tab — and only the editable one reports changes. + const editor = fixture.debugElement + .queryAll(By.directive(MarkdownDescriptionComponent)) + .map(node => node.componentInstance as MarkdownDescriptionComponent) + .find(instance => instance.editable); + expect(editor, "expected an editable markdown description").toBeDefined(); + // The editor is fed the description, not some neighbouring string: what a control shows + // is a separate binding from what its handler writes, and only this reads the former. + expect(editor!.description).toBe("a description"); + editor!.descriptionChange.emit("a new description"); + expect(modelService["updateModelDescription"]).toHaveBeenCalledWith(MID, "a new description"); + + // Framework and format are two identical selects one above the other; a swap between + // them is invisible on screen and would persist each value into the other's column. + // Their displayed values need pinning as well as their handlers: exchanging the two + // [ngModel] inputs alone leaves every handler assertion below intact. + const selects = fixture.debugElement.queryAll(By.css("nz-select")); + expect(selects.length).toBe(2); + expect(selects[0].injector.get(NgModel).model).toBe("pytorch"); + expect(selects[1].injector.get(NgModel).model).toBe("torchscript"); + selects[0].injector.get(NgModel).viewToModelUpdate("tensorflow"); + selects[1].injector.get(NgModel).viewToModelUpdate("onnx"); + fixture.detectChanges(); + + expect(modelService["updateModelFramework"]).toHaveBeenCalledWith(MID, "tensorflow"); + expect(modelService["updateModelFormat"]).toHaveBeenCalledWith(MID, "onnx"); + }); + + it("wires the version uploader's own outputs to the page's state", () => { + oneVersionWithOneFile(); + create(); + openTab("Versions & Files"); + const panel = fixture.debugElement.query(By.directive(VersionUploaderComponent)) + .componentInstance as VersionUploaderComponent; + expect(modelService["retrieveModelVersionList"]).toHaveBeenCalledTimes(1); + + // The panel owns the version flow but not the page's state: without this binding a + // committed version never reaches the picker until a reload. + panel.versionCreated.emit(); + + expect(modelService["retrieveModelVersionList"]).toHaveBeenCalledTimes(2); + + // And this output is the only thing that arms the rename-during-upload block: unbound, + // the guard can never engage on a real page however well it is tested on the instance. + panel.uploadsInFlightChange.emit(true); + fixture.detectChanges(); + + expect(component.uploadsInFlight).toBe(true); + }); }); diff --git a/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts index 18ecd41047..902912e307 100644 --- a/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts +++ b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts @@ -102,6 +102,15 @@ describe("rawMetricForView", () => { const idle = makeMetrics({ inputRows: 0, outputRows: 0 }); expect(rawMetricForView(idle, HeatmapView.IoImbalance)).toBeUndefined(); }); + + it("reports no raw metric for a view it does not know", () => { + // The view is persisted (localStorage), so an unknown value can survive a release. + // It has to read as not-measurable rather than as 0: 0 is a real cost that would + // paint the operator coldest and anchor the scale minimum, which is exactly the + // confusion the undefined/zero split above exists to prevent. + const m = makeMetrics({ dataProcessingTimeNs: 5_000_000, inputRows: 10, outputRows: 4 }); + expect(rawMetricForView(m, "bogus" as HeatmapView)).toBeUndefined(); + }); }); describe("normalizeScores", () => { @@ -177,6 +186,16 @@ describe("formatMetricForView", () => { expect(formatMetricForView(undefined, HeatmapView.TimePerRow)).toBe("—"); expect(formatMetricForView(undefined, HeatmapView.IoImbalance)).toBe("—"); }); + + it("stringifies a value for a view it does not know, rather than blanking it", () => { + // No caller can reach this arm: both of them (workflow-editor and heatmap-legend) derive + // the value from rawMetricForView for the same view, and that returns undefined for + // exactly the views that land here — so an unknown view blanks out at the guard above + // instead. The arm is pinned for its own sake, as the total-function fallback it is: the + // em dash is reserved for not-measurable, so it must not borrow it. A non-integer is + // deliberate — with 42 the plain stringification is indistinguishable from rounding it. + expect(formatMetricForView(42.5, "bogus" as HeatmapView)).toBe("42.5"); + }); }); describe("heatmapViewTitle", () => {
