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", () => {

Reply via email to