Files
ss-tools/frontend/src/lib/models/__tests__/LLMReportModel.test.ts
busya 2b303f92b3 test: bring frontend test coverage to 98% across core lib modules
## 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
2026-06-10 14:59:40 +03:00

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