Stretch B2/F2 de #54, par-dessus le MVP déjà mergé (B1/F1). Backend : le port de téléchargement HF publie une progression débouncée (bytes reçus / total, pourcentage) via le stream de statut du serveur modèle, avec gestion du total inconnu (pas de faux %), du cache hit, de l'annulation et du timeout. Frontend : l'overlay plein-cellule de préparation du serveur affiche la progression réelle (barre, %, octets, source) en mappant le fil de statut, avec la règle « pas de faux % » quand le total est inconnu. Tests : application + infrastructure (téléchargement débouncé, cancel, timeout, cache hit, total inconnu) et vitest (overlay + formatage pur). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
110 lines
3.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
/**
|
|
* Ticket #54 F2 — pure download-progress formatting for the model-server overlay.
|
|
* These lock the wire→display mapping independently of React: byte formatting,
|
|
* the determinate/indeterminate split, and the "no fake %" rule.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import type { ModelServerStatus } from "@/domain";
|
|
import {
|
|
formatBytes,
|
|
describeModelServerDownload,
|
|
modelServerOverlayText,
|
|
} from "./modelServerLaunch";
|
|
|
|
function downloading(
|
|
fields: Partial<{
|
|
downloadedBytes: number | null;
|
|
totalBytes: number | null;
|
|
percent: number | null;
|
|
source: string | null;
|
|
}> = {},
|
|
): ModelServerStatus {
|
|
return {
|
|
state: "downloading",
|
|
downloadedBytes: null,
|
|
totalBytes: null,
|
|
percent: null,
|
|
source: null,
|
|
...fields,
|
|
};
|
|
}
|
|
|
|
describe("formatBytes", () => {
|
|
it("shows whole bytes and SI-scaled larger units", () => {
|
|
expect(formatBytes(0)).toBe("0 o");
|
|
expect(formatBytes(512)).toBe("512 o");
|
|
expect(formatBytes(1000)).toBe("1 Ko");
|
|
expect(formatBytes(1_500_000)).toBe("1.5 Mo");
|
|
expect(formatBytes(4_200_000_000)).toBe("4.2 Go");
|
|
});
|
|
|
|
it("guards non-finite / negative", () => {
|
|
expect(formatBytes(-5)).toBe("0 o");
|
|
expect(formatBytes(Number.NaN)).toBe("0 o");
|
|
});
|
|
});
|
|
|
|
describe("describeModelServerDownload", () => {
|
|
it("returns null for every non-downloading state", () => {
|
|
expect(describeModelServerDownload(undefined)).toBeNull();
|
|
expect(describeModelServerDownload({ state: "probing" })).toBeNull();
|
|
expect(describeModelServerDownload({ state: "starting" })).toBeNull();
|
|
expect(
|
|
describeModelServerDownload({ state: "ready", reused: false }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("exposes percent + a 'X / Y' bytes label when both bounds are known", () => {
|
|
const p = describeModelServerDownload(
|
|
downloading({
|
|
downloadedBytes: 1_500_000,
|
|
totalBytes: 3_000_000,
|
|
percent: 50,
|
|
source: "unsloth/Qwen3-Coder-30B",
|
|
}),
|
|
);
|
|
expect(p).toEqual({
|
|
percent: 50,
|
|
bytesLabel: "1.5 Mo / 3 Mo",
|
|
source: "unsloth/Qwen3-Coder-30B",
|
|
});
|
|
});
|
|
|
|
it("clamps percent to 0..100", () => {
|
|
expect(describeModelServerDownload(downloading({ percent: 137 }))?.percent).toBe(
|
|
100,
|
|
);
|
|
expect(describeModelServerDownload(downloading({ percent: -3 }))?.percent).toBe(
|
|
0,
|
|
);
|
|
});
|
|
|
|
it("is indeterminate (percent null) when the total is unknown", () => {
|
|
const p = describeModelServerDownload(
|
|
downloading({ downloadedBytes: 800_000, totalBytes: null, percent: null }),
|
|
);
|
|
expect(p).toEqual({ percent: null, bytesLabel: "800 Ko", source: null });
|
|
});
|
|
|
|
it("has no bytes label when nothing about size is known", () => {
|
|
expect(describeModelServerDownload(downloading())).toEqual({
|
|
percent: null,
|
|
bytesLabel: null,
|
|
source: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("modelServerOverlayText (unchanged F1 titles)", () => {
|
|
it("keeps the download / loading titles", () => {
|
|
expect(modelServerOverlayText(downloading())).toBe(
|
|
"Téléchargement du modèle…",
|
|
);
|
|
expect(modelServerOverlayText({ state: "starting" })).toBe(
|
|
"Chargement du serveur…",
|
|
);
|
|
expect(modelServerOverlayText({ state: "ready", reused: true })).toBeNull();
|
|
});
|
|
});
|