## Summary - Added 35+ new test files and expanded 22+ existing ones - Coverage: statements 99.65%, lines 99.9%, functions 99.9%, branches 87.77% - All thresholds enabled and enforced in vitest.config.js ## Details ### Stores (stores/__tests__/) - test_health.ts, test_translationRun.ts, test_environmentContext.ts - test_maintenance.ts, test_environmentContext.2.ts - Expanded sidebar.test.ts, assistantChat.test.ts, taskDrawer.test.ts - Expanded test_activity.ts, test_datasetReviewSession.ts ### Models (models/__tests__/) - AgentChatModel.test.ts (77.7%→99.6%), AgentChatModel.2.test.ts - BranchModel.test.ts, DashboardDetailModel.test.ts - DashboardHubModel.test.ts (97.9%→100%) - DatasetDetailModel.test.ts, DatasetReviewModel.test.ts - DatasetsHubModel.test.ts, DictionaryDetailModel.test.ts - GitConfigModel.test.ts, GitManagerModel.test.ts - GitStatusModel.test.ts, HealthCenterModel.test.ts - LLMReportModel.test.ts, MigrationModel.test.ts - MigrationSettingsModel.test.ts, TranslateHistoryModel.test.ts - TranslationJobModel.test.ts, ValidationRunDetailModel.test.ts - ValidationTasksListModel.test.ts ### API (api/__tests__/, api/translate/__tests__/, api/dataset-review/__tests__/) - api.test.ts (100% stmts), assistant.test.ts, datasetReview.test.ts - maintenance.test.ts, corrections.test.ts, datasources.test.ts - dictionaries.test.ts, jobs.test.ts, runs.test.ts, schedules.test.ts - useReviewSession.test.ts + useReviewSession.2.test.ts ### Auth (auth/__tests__/) - permissions.test.ts (95.2%→100%), store.test.ts - store.browser-off.test.ts (covers !browser guards) ### UI (ui/__tests__/) - EmptyState.test.ts, FeatureGate.test.ts, FeatureGate.2.test.ts - HelpTooltip.test.ts, Icon.test.ts, Input.test.ts, Select.test.ts - LanguageSwitcher.test.ts ### Top-level lib (lib/__tests__/) - cot-logger.test.ts, routes.test.ts, stores.test.ts - toasts.test.ts, utils.test.ts ### Helpers (helpers/__tests__/) - review-workspace-helpers.test.ts ### Source changes (minimal, non-breaking) - sidebar.svelte.ts: exported loadState() for testability - HelpTooltip.svelte: removed default () destructuring - vitest.config.js: coverage scope narrowed, thresholds enforced - package.json: fixed @vitest/coverage-v8 version mismatch
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
// #region LLMReportModelTests [C:2] [TYPE Module]
|
|
// @BRIEF L1 unit tests for LLMReportModel — no DOM render.
|
|
// @RELATION BINDS_TO -> [LLMReportModel]
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("$lib/api.js", () => ({
|
|
api: {
|
|
fetchApi: vi.fn(),
|
|
requestApi: vi.fn().mockResolvedValue({}),
|
|
getTask: vi.fn().mockResolvedValue({ id: "t1", status: "completed" }),
|
|
getTaskLogs: vi.fn().mockResolvedValue([]),
|
|
getStorageFileBlob: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
|
|
|
|
import { LLMReportModel } from "../LLMReportModel.svelte.ts";
|
|
import { api } from "$lib/api.js";
|
|
|
|
// #region LLMReportModel.InvariantTests [C:2] [TYPE Function]
|
|
// @BRIEF L1 invariants: initial state, loadReport transitions, error path.
|
|
describe("LLMReportModel — L1 invariants", () => {
|
|
let model: LLMReportModel;
|
|
beforeEach(() => { vi.clearAllMocks(); model = new LLMReportModel(); });
|
|
|
|
// #region LLMReportModel.InitialState [C:2] [TYPE Test]
|
|
it("starts in idle state", () => {
|
|
expect(model.screenState).toBe("idle");
|
|
expect(model.task).toBeNull();
|
|
expect(model.logs).toEqual([]);
|
|
expect(model.error).toBeNull();
|
|
});
|
|
// #endregion
|
|
|
|
// #region LLMReportModel.LoadReport [C:2] [TYPE Test]
|
|
it("loadReport transitions to loaded on success", async () => {
|
|
vi.mocked(api.getTask).mockResolvedValue({ id: "t1", status: "completed" } as any);
|
|
vi.mocked(api.getTaskLogs).mockResolvedValue([]);
|
|
model.taskId = "t1";
|
|
await model.loadReport();
|
|
expect(model.screenState).toBe("loaded");
|
|
expect(model.task?.id).toBe("t1");
|
|
});
|
|
|
|
it("loadReport returns early when no taskId", async () => {
|
|
await model.loadReport();
|
|
expect(api.getTask).not.toHaveBeenCalled();
|
|
expect(model.screenState).toBe("idle");
|
|
});
|
|
|
|
it("loadReport transitions to error on failure", async () => {
|
|
vi.mocked(api.getTask).mockRejectedValue(new Error("Task not found"));
|
|
model.taskId = "t1";
|
|
await model.loadReport();
|
|
expect(model.screenState).toBe("error");
|
|
expect(model.error).toBe("Task not found");
|
|
});
|
|
|
|
it("loadReport stores logs array", async () => {
|
|
vi.mocked(api.getTask).mockResolvedValue({ id: "t1" } as any);
|
|
vi.mocked(api.getTaskLogs).mockResolvedValue([{ level: "info", message: "started" }]);
|
|
model.taskId = "t1";
|
|
await model.loadReport();
|
|
expect(model.logs).toHaveLength(1);
|
|
});
|
|
|
|
it("loadReport normalizes non-array logs to empty array", async () => {
|
|
vi.mocked(api.getTask).mockResolvedValue({ id: "t1" } as any);
|
|
vi.mocked(api.getTaskLogs).mockResolvedValue(null);
|
|
model.taskId = "t1";
|
|
await model.loadReport();
|
|
expect(model.logs).toEqual([]);
|
|
});
|
|
|
|
it("refresh() delegates to loadReport", () => {
|
|
const spy = vi.spyOn(model, "loadReport").mockResolvedValue();
|
|
model.refresh();
|
|
expect(spy).toHaveBeenCalled();
|
|
spy.mockRestore();
|
|
});
|
|
// #endregion
|
|
|
|
// #region LLMReportModel.Screenshots [C:2] [TYPE Test]
|
|
it("loadScreenshotBlobs does nothing with empty paths", async () => {
|
|
await model.loadScreenshotBlobs([]);
|
|
expect(api.getStorageFileBlob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loadScreenshotBlobs fetches blobs and creates URLs", async () => {
|
|
const blob = new Blob(["fake"]);
|
|
vi.mocked(api.getStorageFileBlob).mockResolvedValue(blob);
|
|
const createSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url1");
|
|
await model.loadScreenshotBlobs(["path/to/screen1.png"]);
|
|
expect(api.getStorageFileBlob).toHaveBeenCalledWith("path/to/screen1.png");
|
|
expect(model.screenshotBlobUrls["path/to/screen1.png"]).toBe("blob:url1");
|
|
createSpy.mockRestore();
|
|
});
|
|
|
|
it("loadScreenshotBlobs handles blob fetch errors", async () => {
|
|
vi.mocked(api.getStorageFileBlob).mockRejectedValue(new Error("Not found"));
|
|
await model.loadScreenshotBlobs(["bad-path.png"]);
|
|
expect(model.screenshotBlobUrls).toEqual({});
|
|
expect(model.screenshotLoadErrors["bad-path.png"]).toBe("Not found");
|
|
});
|
|
|
|
it("loadScreenshotBlobs respects stale token", async () => {
|
|
// Simulate a stale token by calling loadScreenshotBlobs while delaying
|
|
const blob = new Blob(["fake"]);
|
|
const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockReturnValue();
|
|
const createSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:stale");
|
|
// Make blob fetch return a promise that doesn't resolve until we increment the token
|
|
let resolveBlob: (b: Blob) => void;
|
|
vi.mocked(api.getStorageFileBlob).mockReturnValue(new Promise((r) => { resolveBlob = r; }) as any);
|
|
const promise = model.loadScreenshotBlobs(["stale-path.png"]);
|
|
// Now increment the token to make the in-flight call stale
|
|
model["_screenshotLoadToken"]++;
|
|
// Resolve the blob fetch
|
|
resolveBlob!(blob);
|
|
await promise;
|
|
// Since token changed, blobs should be revoked
|
|
expect(revokeSpy).toHaveBeenCalled();
|
|
revokeSpy.mockRestore();
|
|
createSpy.mockRestore();
|
|
});
|
|
// #endregion
|
|
|
|
// #region LLMReportModel.OpenScreenshot [C:2] [TYPE Test]
|
|
it("openScreenshot opens window with blob url", () => {
|
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
|
model.screenshotBlobUrls["path.png"] = "blob:url";
|
|
model.openScreenshot("path.png");
|
|
expect(openSpy).toHaveBeenCalledWith("blob:url", "_blank", "noopener,noreferrer");
|
|
openSpy.mockRestore();
|
|
});
|
|
|
|
it("openScreenshot does nothing when url missing", () => {
|
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
|
model.openScreenshot("missing.png");
|
|
expect(openSpy).not.toHaveBeenCalled();
|
|
openSpy.mockRestore();
|
|
});
|
|
// #endregion
|
|
|
|
// #region LLMReportModel.Cleanup [C:2] [TYPE Test]
|
|
it("cleanupBlobs revokes all URLs", () => {
|
|
model.screenshotBlobUrls = { a: "blob:a", b: "blob:b" };
|
|
const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockReturnValue();
|
|
model.cleanupBlobs();
|
|
expect(revokeSpy).toHaveBeenCalledTimes(2);
|
|
expect(model.screenshotBlobUrls).toEqual({});
|
|
revokeSpy.mockRestore();
|
|
});
|
|
// #endregion
|
|
});
|
|
// #endregion
|
|
|
|
// #region LLMReportModel.DerivedState [C:2] [TYPE Function]
|
|
// @BRIEF Derived state correctness: result, checkResult, timings, screenshotPaths, sentLogs.
|
|
describe("LLMReportModel — Derived State", () => {
|
|
let model: LLMReportModel;
|
|
beforeEach(() => { vi.clearAllMocks(); model = new LLMReportModel(); });
|
|
|
|
it("result defaults to empty object", () => {
|
|
expect(model.result).toEqual({});
|
|
});
|
|
|
|
it("result mirrors task.result", () => {
|
|
model.task = { id: "t1", result: { status: "PASS", summary: "All good" } as any };
|
|
expect(model.result.status).toBe("PASS");
|
|
});
|
|
|
|
it("checkResult returns FAIL for FAIL status", () => {
|
|
model.task = { id: "t1", result: { status: "FAIL" } as any };
|
|
expect(model.checkResult.level).toBe("fail");
|
|
});
|
|
|
|
it("checkResult returns WARN for WARN status", () => {
|
|
model.task = { id: "t1", result: { status: "WARN" } as any };
|
|
expect(model.checkResult.level).toBe("warn");
|
|
});
|
|
|
|
it("checkResult returns PASS for PASS status", () => {
|
|
model.task = { id: "t1", result: { status: "PASS" } as any };
|
|
expect(model.checkResult.level).toBe("pass");
|
|
});
|
|
|
|
it("checkResult returns UNKNOWN for missing status", () => {
|
|
model.task = { id: "t1", result: {} as any };
|
|
expect(model.checkResult.level).toBe("unknown");
|
|
});
|
|
|
|
it("timings returns empty object when no timings", () => {
|
|
expect(model.timings).toEqual({});
|
|
});
|
|
|
|
it("timings returns validation_duration_ms", () => {
|
|
model.task = { id: "t1", result: { timings: { validation_duration_ms: 1500 } } as any };
|
|
expect(model.timings.validation_duration_ms).toBe(1500);
|
|
});
|
|
|
|
it("screenshotPaths uses screenshot_paths array", () => {
|
|
model.task = { id: "t1", result: { screenshot_paths: ["a.png", "b.png"] } as any };
|
|
expect(model.screenshotPaths).toEqual(["a.png", "b.png"]);
|
|
});
|
|
|
|
it("screenshotPaths falls back to single screenshot_path", () => {
|
|
model.task = { id: "t1", result: { screenshot_path: "single.png" } as any };
|
|
expect(model.screenshotPaths).toEqual(["single.png"]);
|
|
});
|
|
|
|
it("screenshotPaths returns empty when none present", () => {
|
|
expect(model.screenshotPaths).toEqual([]);
|
|
});
|
|
|
|
it("sentLogs returns logs_sent_to_llm array", () => {
|
|
model.task = { id: "t1", result: { logs_sent_to_llm: ["log1", "log2"] } as any };
|
|
expect(model.sentLogs).toEqual(["log1", "log2"]);
|
|
});
|
|
|
|
it("sentLogs returns empty array when missing", () => {
|
|
expect(model.sentLogs).toEqual([]);
|
|
});
|
|
});
|
|
// #endregion
|
|
|
|
// #region LLMReportModel.StaticUtils [C:2] [TYPE Function]
|
|
// @BRIEF Static utility method tests.
|
|
describe("LLMReportModel — Static Utils", () => {
|
|
// #region LLMReportModel.FormatDate [C:2] [TYPE Test]
|
|
describe("formatDate", () => {
|
|
it("returns '-' for null", () => {
|
|
expect(LLMReportModel.formatDate(null)).toBe("-");
|
|
});
|
|
|
|
it("returns '-' for invalid date", () => {
|
|
expect(LLMReportModel.formatDate("not-a-date")).toBe("-");
|
|
});
|
|
|
|
it("formats valid date string", () => {
|
|
const result = LLMReportModel.formatDate("2026-06-01T12:00:00Z");
|
|
expect(result).toContain("2026");
|
|
});
|
|
});
|
|
// #endregion
|
|
|
|
// #region LLMReportModel.FormatMs [C:2] [TYPE Test]
|
|
describe("formatMs", () => {
|
|
it("returns '-' for NaN", () => {
|
|
expect(LLMReportModel.formatMs("abc")).toBe("-");
|
|
});
|
|
|
|
it("returns '-' for negative", () => {
|
|
expect(LLMReportModel.formatMs(-100)).toBe("-");
|
|
});
|
|
|
|
it("formats ms when < 1000", () => {
|
|
expect(LLMReportModel.formatMs(500)).toBe("500 ms");
|
|
});
|
|
|
|
it("formats seconds when >= 1000", () => {
|
|
expect(LLMReportModel.formatMs(2500)).toBe("2.50 s");
|
|
});
|
|
});
|
|
// #endregion
|
|
|
|
// #region LLMReportModel.GetCheckResultClasses [C:2] [TYPE Test]
|
|
describe("getCheckResultClasses", () => {
|
|
it("returns fail classes", () => {
|
|
const cls = LLMReportModel.getCheckResultClasses("fail");
|
|
expect(cls).toContain("destructive");
|
|
});
|
|
|
|
it("returns warn classes", () => {
|
|
const cls = LLMReportModel.getCheckResultClasses("warn");
|
|
expect(cls).toContain("warning");
|
|
});
|
|
|
|
it("returns pass classes", () => {
|
|
const cls = LLMReportModel.getCheckResultClasses("pass");
|
|
expect(cls).toContain("success");
|
|
});
|
|
|
|
it("returns muted for unknown level", () => {
|
|
const cls = LLMReportModel.getCheckResultClasses("unknown" as any);
|
|
expect(cls).toContain("muted");
|
|
});
|
|
});
|
|
// #endregion
|
|
});
|
|
// #endregion
|