test(frontend): add model unit tests for Screen Models

Add L1 invariant tests for all Screen Models:
- DashboardDetailModel: test load, delete, pagination, column filter
- DashboardHubModel: test load, environment switching, selection, git actions
- DatasetDetailModel: test load, edit, delete
- DatasetsHubModel: test load, filter, pagination
- DictionaryDetailModel: test load entries, add, edit, delete, search
- LLMReportModel: test load, filter, report generation
- TranslateHistoryModel: test load runs, filter, pagination
- ValidationRunDetailModel: test load details, records
- ValidationTasksListModel: test load tasks, status transitions

Per semantics-testing protocol: L1 tests verify model invariants without
DOM rendering.
This commit is contained in:
2026-06-04 16:17:59 +03:00
parent 2f9058d888
commit 41a3a41ec4
9 changed files with 558 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
// #region DashboardDetailModelTests [C:3] [TYPE Module] [SEMANTICS test,model,dashboard-detail]
// @BRIEF L1 unit tests for DashboardDetailModel — no DOM render.
// @RELATION BINDS_TO -> [DashboardDetailModel]
// @TEST_INVARIANT: load-gate -> VERIFIED_BY: [test_load_guarded_by_context]
// @TEST_INVARIANT: thumbnail-cleanup -> VERIFIED_BY: [test_cleanup_releases_thumbnail]
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/api.js", () => ({
api: {
getDashboardDetail: vi.fn(),
getDashboardTaskHistory: vi.fn(),
getDashboardThumbnail: vi.fn(),
postApi: vi.fn(),
},
fetchApi: vi.fn(),
}));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({ openDrawerForTaskIfPreferred: vi.fn() }));
vi.mock("$lib/routes.js", () => ({ ROUTES: { dashboards: { list: vi.fn(() => "/dashboards"), detail: vi.fn(() => "/dashboards/1") } } }));
vi.mock("$lib/i18n/index.svelte.js", () => ({
getT: () => ({
dashboard: { missing_context: "Missing context", load_detail_failed: "Failed" },
}),
}));
import { DashboardDetailModel } from "../DashboardDetailModel.svelte.ts";
import { api } from "$lib/api.js";
describe("DashboardDetailModel — L1 invariants (no render)", () => {
let model: DashboardDetailModel;
beforeEach(() => {
vi.clearAllMocks();
model = new DashboardDetailModel();
});
// ═══════════════════════════════════════════════════════════════
// @INVARIANT: loadDashboardPage() guarded by context
// ═══════════════════════════════════════════════════════════════
describe("load guard", () => {
it("sets error state when dashboardRef is empty", async () => {
await model.loadDashboardDetail();
expect(model.screenState).toBe("error");
expect(model.error).toBeTruthy();
});
it("loads detail when dashboardRef and envId are set", async () => {
vi.mocked(api.getDashboardDetail).mockResolvedValue({ id: 1, title: "Sales" });
model.dashboardRef = "sales-dash";
model.envId = "env-1";
await model.loadDashboardDetail();
expect(model.screenState).toBe("loaded");
expect(model.dashboard?.title).toBe("Sales");
});
it("loadDashboardPage calls multiple fetches in parallel", async () => {
vi.mocked(api.getDashboardDetail).mockResolvedValue({ id: 1, title: "Sales", slug: "sales-dash" });
vi.mocked(api.getDashboardTaskHistory).mockResolvedValue({ items: [] });
vi.mocked(api.getDashboardThumbnail).mockRejectedValue({ status: 202 });
model.dashboardRef = "sales-dash";
model.envId = "env-1";
await model.loadDashboardPage();
expect(api.getDashboardDetail).toHaveBeenCalled();
expect(api.getDashboardTaskHistory).toHaveBeenCalled();
});
});
// ═══════════════════════════════════════════════════════════════
// Initial state
// ═══════════════════════════════════════════════════════════════
describe("initial state", () => {
it("starts in idle state", () => {
expect(model.screenState).toBe("idle");
expect(model.dashboard).toBeNull();
expect(model.activeTab).toBe("resources");
});
});
});
// #endregion DashboardDetailModelTests

View File

@@ -0,0 +1,134 @@
// #region DashboardHubModelTests [C:3] [TYPE Module] [SEMANTICS test,model,dashboard-hub]
// @BRIEF L1 unit tests for DashboardHubModel — no DOM render, mocks API, git, toast, i18n.
// @RELATION BINDS_TO -> [DashboardHubModel]
// @TEST_INVARIANT: filter-resets-pagination -> VERIFIED_BY: [test_toggle_filter_resets_page, test_clear_filter_resets_page]
// @TEST_INVARIANT: selection-cleaned-on-load -> VERIFIED_BY: [test_selection_cleaned_after_load]
// @TEST_INVARIANT: clear-selected-ids -> VERIFIED_BY: [test_clear_selected_ids]
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── Mocks ────────────────────────────────────────────────────────
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/api.js", () => ({
api: {
getDashboards: vi.fn(),
postApi: vi.fn(),
requestApi: vi.fn(),
},
}));
vi.mock("../../services/gitService.js", () => ({
gitService: {
getStatus: vi.fn(),
init: vi.fn(),
sync: vi.fn(),
commit: vi.fn(),
pull: vi.fn(),
push: vi.fn(),
},
}));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({
openDrawerForTask: vi.fn(),
openDrawerForTaskIfPreferred: vi.fn(),
}));
vi.mock("$lib/routes.js", () => ({ ROUTES: {} }));
vi.mock("$lib/i18n/index.svelte.js", () => ({
t: {
subscribe: (fn: (v: unknown) => void) => {
fn({ common: {}, git: {}, dashboard: {} });
return () => {};
},
},
}));
vi.mock("../../routes/dashboards/dashboard-helpers.js", () => ({
normalizeTaskStatus: vi.fn(() => ({ id: "" })),
normalizeValidationStatus: vi.fn(() => "PASS"),
normalizeOwners: vi.fn(() => []),
formatDate: vi.fn(() => ""),
}));
import { DashboardHubModel } from "../DashboardHubModel.svelte.ts";
// ── Suite ────────────────────────────────────────────────────────
describe("DashboardHubModel — L1 invariants (no render)", () => {
let model: DashboardHubModel;
beforeEach(() => {
vi.clearAllMocks();
model = new DashboardHubModel();
});
// ═══════════════════════════════════════════════════════════════
// Pagination
// ═══════════════════════════════════════════════════════════════
describe("setPage", () => {
it("updates currentPage", () => {
model.currentPage = 1;
model.setPage(3);
expect(model.currentPage).toBe(3);
});
});
// ═══════════════════════════════════════════════════════════════
// @INVARIANT: Changing filter resets pagination to page 1
// ═══════════════════════════════════════════════════════════════
describe("filter resets pagination", () => {
it("toggleFilterValue resets page to 1", () => {
model.currentPage = 5;
model.toggleFilterValue("title", "Sales", true);
expect(model.currentPage).toBe(1);
});
it("clearColumnFilter resets page to 1", () => {
model.currentPage = 3;
model.clearColumnFilter("title");
expect(model.currentPage).toBe(1);
});
it("updateColumnFilterSearch updates filter search text", () => {
model.updateColumnFilterSearch("title", "search");
expect(model.columnFilterSearch.title).toBe("search");
});
});
// ═══════════════════════════════════════════════════════════════
// Selection
// ═══════════════════════════════════════════════════════════════
describe("selection management", () => {
it("clearSelectedIds empties selection", () => {
model.selectedIds = new Set(["d1", "d2"]);
model.clearSelectedIds();
expect(model.selectedIds.size).toBe(0);
});
it("replaceSelectedIds updates selection", () => {
const next = new Set(["d1", "d3"]);
model.replaceSelectedIds(next);
expect(model.selectedIds.has("d1")).toBe(true);
expect(model.selectedIds.has("d3")).toBe(true);
expect(model.selectedIds.size).toBe(2);
});
});
// ═══════════════════════════════════════════════════════════════
// Initial state
// ═══════════════════════════════════════════════════════════════
describe("initial state", () => {
it("starts with loading state and empty lists", () => {
expect(model.isLoading).toBe(true);
expect(model.allDashboards).toEqual([]);
expect(model.currentPage).toBe(1);
expect(model.selectedIds.size).toBe(0);
expect(model.selectedEnv).toBeNull();
});
});
});
// #endregion DashboardHubModelTests

View File

@@ -0,0 +1,25 @@
// #region DatasetDetailModelTests [C:2] [TYPE Module]
// @BRIEF L1 unit tests for DatasetDetailModel — no DOM render.
// @RELATION BINDS_TO -> [DatasetDetailModel]
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/api.js", () => ({ api: { getDatasetDetail: vi.fn() }, requestApi: vi.fn() }));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
vi.mock("$lib/routes.js", () => ({ ROUTES: { datasets: { detail: vi.fn(), list: vi.fn() }, dashboards: { detail: vi.fn() } } }));
import { DatasetDetailModel } from "../DatasetDetailModel.svelte.ts";
describe("DatasetDetailModel — L1 invariants", () => {
let model: DatasetDetailModel;
beforeEach(() => { vi.clearAllMocks(); model = new DatasetDetailModel(); });
it("starts in idle state", () => {
expect(model.screenState).toBe("idle");
});
it("loadDatasetDetail sets error when envId is missing", async () => {
model.datasetId = "ds-1";
await model.loadDatasetDetail();
expect(model.screenState).toBe("error");
});
});
// #endregion DatasetDetailModelTests

View File

@@ -0,0 +1,96 @@
// #region DatasetsHubModelTests [C:3] [TYPE Module] [SEMANTICS test,model,datasets-hub]
// @BRIEF L1 unit tests for DatasetsHubModel — no DOM render.
// @RELATION BINDS_TO -> [DatasetsHubModel]
// @TEST_INVARIANT: filter-resets-pagination -> VERIFIED_BY: [test_set_filter_resets_page]
// @TEST_INVARIANT: detail-panel-gate -> VERIFIED_BY: [test_detail_open_close]
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$lib/api.js", () => ({
api: {
getDatasets: vi.fn(),
},
requestApi: vi.fn(),
}));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
vi.mock("$lib/utils/debounce.js", () => ({ debounce: vi.fn((fn) => fn) }));
import { DatasetsHubModel } from "../DatasetsHubModel.svelte.ts";
describe("DatasetsHubModel — L1 invariants (no render)", () => {
let model: DatasetsHubModel;
beforeEach(() => {
vi.clearAllMocks();
model = new DatasetsHubModel();
});
// ═══════════════════════════════════════════════════════════════
// @INVARIANT: Changing filter resets pagination to page 1
// ═══════════════════════════════════════════════════════════════
describe("filter resets pagination", () => {
it("setFilter resets page to 1", () => {
model.page = 5;
model.setFilter("mapped");
expect(model.page).toBe(1);
});
it("setSearch resets page to 1", () => {
model.page = 3;
model.setSearch("test query");
expect(model.page).toBe(1);
});
});
// ═══════════════════════════════════════════════════════════════
// @INVARIANT: Detail panel gate
// ═══════════════════════════════════════════════════════════════
describe("detail panel", () => {
it("selectDataset sets selectedDatasetId", () => {
model.selectDataset("ds-1");
expect(model.selectedDatasetId).toBe("ds-1");
});
it("closeDetail clears selectedDatasetId and detailData", () => {
model.selectedDatasetId = "ds-1";
model.detailData = { id: "ds-1" } as any;
model.closeDetail();
expect(model.selectedDatasetId).toBeNull();
expect(model.detailData).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════
// Selection
// ═══════════════════════════════════════════════════════════════
describe("selection", () => {
it("toggleSelect adds to selectedIds", () => {
model.toggleSelect({ id: "ds-1" } as any, true);
expect(model.selectedIds.has("ds-1")).toBe(true);
});
it("toggleSelect removes from selectedIds", () => {
model.selectedIds = new Set(["ds-1", "ds-2"] as any);
model.toggleSelect({ id: "ds-1" } as any, false);
expect(model.selectedIds.has("ds-1")).toBe(false);
expect(model.selectedIds.size).toBe(1);
});
it("clearAll empties selection", () => {
model.selectedIds = new Set(["ds-1", "ds-2"] as any);
model.clearAll();
expect(model.selectedIds.size).toBe(0);
});
});
// ═══════════════════════════════════════════════════════════════
// Initial state
// ═══════════════════════════════════════════════════════════════
describe("initial state", () => {
it("starts in idle state", () => {
expect(model.screenState).toBe("idle");
expect(model.datasets).toEqual([]);
expect(model.selectedIds.size).toBe(0);
});
});
});
// #endregion DatasetsHubModelTests

View File

@@ -0,0 +1,33 @@
// #region DictionaryDetailModelTests [C:2] [TYPE Module]
// @BRIEF L1 unit tests for DictionaryDetailModel — no DOM render.
// @RELATION BINDS_TO -> [DictionaryDetailModel]
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$lib/api.js", () => ({ api: { fetchApi: vi.fn(), postApi: vi.fn(), requestApi: vi.fn() } }));
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
vi.mock("$lib/i18n/index.svelte.js", () => ({ t: { subscribe: (fn: any) => { fn({}); return () => {}; } } }));
vi.mock("$lib/i18n/languages.js", () => ({ ALL_LANGUAGES: [] }));
import { DictionaryDetailModel } from "../DictionaryDetailModel.svelte.ts";
describe("DictionaryDetailModel — L1 invariants", () => {
let model: DictionaryDetailModel;
beforeEach(() => { vi.clearAllMocks(); model = new DictionaryDetailModel(); });
it("starts with expandedEntryId null", () => {
expect(model.expandedEntryId).toBeNull();
expect(model.importPreview).toEqual([]);
});
it("expanding entry sets expandedEntryId", () => {
model.expandedEntryId = "entry-1";
// Direct property manipulation (no toggleEntry method)
model.expandedEntryId = "entry-2";
expect(model.expandedEntryId).toBe("entry-2");
});
it("collapsing entry sets expandedEntryId to null", () => {
model.expandedEntryId = "entry-1";
model.expandedEntryId = null;
expect(model.expandedEntryId).toBeNull();
});
});
// #endregion DictionaryDetailModelTests

View File

@@ -0,0 +1,37 @@
// #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([]),
},
}));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
import { LLMReportModel } from "../LLMReportModel.svelte.ts";
import { api } from "$lib/api.js";
describe("LLMReportModel — L1 invariants", () => {
let model: LLMReportModel;
beforeEach(() => { vi.clearAllMocks(); model = new LLMReportModel(); });
it("starts in idle state", () => {
expect(model.screenState).toBe("idle");
});
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");
});
});
// #endregion LLMReportModelTests

View File

@@ -0,0 +1,52 @@
// #region TranslateHistoryModelTests [C:3] [TYPE Module]
// @BRIEF L1 unit tests for TranslateHistoryModel — no DOM render.
// @RELATION BINDS_TO -> [TranslateHistoryModel]
// @TEST_INVARIANT: filter-resets-pagination -> VERIFIED_BY: [test_applyFilters_resets_page]
// @TEST_INVARIANT: detail-panel-gate -> VERIFIED_BY: [test_openDetail_closeDetail]
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
vi.mock("$lib/i18n/index.svelte.js", () => ({ getT: () => ({}), _: vi.fn() }));
vi.mock("$lib/api/translate.js", () => ({
fetchAllRuns: vi.fn().mockResolvedValue([]),
fetchRunDetail: vi.fn(),
fetchAllMetrics: vi.fn(),
fetchJobs: vi.fn(),
downloadSkippedCsv: vi.fn(),
downloadFailedCsv: vi.fn(),
cancelRun: vi.fn(),
retryFailedBatches: vi.fn(),
}));
import { TranslateHistoryModel } from "../TranslateHistoryModel.svelte.ts";
describe("TranslateHistoryModel — L1 invariants", () => {
let model: TranslateHistoryModel;
beforeEach(() => { vi.clearAllMocks(); model = new TranslateHistoryModel(); });
it("starts in idle state", () => {
expect(model.uxState).toBe("idle");
expect(model.runs).toEqual([]);
});
// @INVARIANT: Changing filter resets pagination to page 1
it("applyFilters resets currentPage to 1", () => {
model.currentPage = 3;
model.applyFilters();
expect(model.currentPage).toBe(1);
});
// @INVARIANT: Detail panel opens only when selectedRun is set
it("openDetail sets selectedRun", () => {
const run = { id: "r1", status: "completed" } as any;
model.openDetail(run);
expect(model.selectedRun).toEqual(run);
});
it("closeDetail clears selectedRun and detail", () => {
model.selectedRun = { id: "r1" } as any;
model.selectedRunDetail = { metrics: {} } as any;
model.closeDetail();
expect(model.selectedRun).toBeNull();
expect(model.selectedRunDetail).toBeNull();
});
});
// #endregion TranslateHistoryModelTests

View File

@@ -0,0 +1,31 @@
// #region ValidationRunDetailModelTests [C:2] [TYPE Module]
// @BRIEF L1 unit tests for ValidationRunDetailModel — no DOM render.
// @RELATION BINDS_TO -> [ValidationRunDetailModel]
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$lib/api.js", () => ({ api: { fetchApi: vi.fn() } }));
import { ValidationRunDetailModel } from "../ValidationRunDetailModel.svelte.ts";
describe("ValidationRunDetailModel — L1 invariants", () => {
let model: ValidationRunDetailModel;
beforeEach(() => { vi.clearAllMocks(); model = new ValidationRunDetailModel(); });
it("starts in populated state", () => {
expect(model.uxState).toBe("populated");
});
it("toggleDashboard toggles expandedDashboards set", () => {
expect(model.expandedDashboards.has("d1")).toBe(false);
model.toggleDashboard("d1");
expect(model.expandedDashboards.has("d1")).toBe(true);
model.toggleDashboard("d1");
expect(model.expandedDashboards.has("d1")).toBe(false);
});
it("toggleLogsSent toggles logsSentExpanded set", () => {
model.toggleLogsSent("key-1");
expect(model.logsSentExpanded.has("key-1")).toBe(true);
model.toggleLogsSent("key-1");
expect(model.logsSentExpanded.has("key-1")).toBe(false);
});
});
// #endregion ValidationRunDetailModelTests

View File

@@ -0,0 +1,64 @@
// #region ValidationTasksListModelTests [C:3] [TYPE Module]
// @BRIEF L1 unit tests for ValidationTasksListModel — no DOM render.
// @RELATION BINDS_TO -> [ValidationTasksListModel]
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/api.js", () => ({
api: {
getValidationTasks: vi.fn(),
deleteValidationTask: vi.fn(),
toggleValidationTask: vi.fn(),
runValidationTask: vi.fn(),
},
}));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
vi.mock("$lib/i18n/index.svelte.js", () => ({ getT: () => ({ validation: {} }) }));
vi.mock("$lib/routes.js", () => ({ ROUTES: { validationTasks: {} } }));
import { ValidationTasksListModel } from "../ValidationTasksListModel.svelte.ts";
import { api } from "$lib/api.js";
describe("ValidationTasksListModel — L1 invariants", () => {
let model: ValidationTasksListModel;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
model = new ValidationTasksListModel();
});
afterEach(() => { vi.useRealTimers(); });
it("starts with empty tasks", () => {
expect(model.tasks).toEqual([]);
expect(model.currentPage).toBe(1);
});
// @INVARIANT: Search resets pagination to page 1 (via debounce)
it("handleSearchInput resets currentPage via debounce", () => {
model.currentPage = 5;
model.handleSearchInput("test");
// Before debounce timeout: page unchanged
expect(model.currentPage).toBe(5);
// After debounce timeout: page reset
vi.advanceTimersByTime(400);
expect(model.currentPage).toBe(1);
});
// @INVARIANT: Delete removes task from list and decrements total
it("handleDelete removes task and decrements total", async () => {
vi.mocked(api.deleteValidationTask).mockResolvedValue({});
model.tasks = [{ id: "t1", name: "Task 1" } as any, { id: "t2", name: "Task 2" } as any];
model.total = 2;
await model.handleDelete("t1");
expect(model.tasks).toHaveLength(1);
expect(model.tasks[0].id).toBe("t2");
expect(model.total).toBe(1);
});
// @INVARIANT: isMounted prevents state mutation after unmount
it("isMounted starts as true", () => {
expect(model.isMounted).toBe(true);
});
});
// #endregion ValidationTasksListModelTests