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
The following commit(s) were added to refs/heads/main by this push:
new f13f175c80 test(frontend): cover the model-detail page, admin settings
and heatmap scoring (#8290)
f13f175c80 is described below
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", () => {