diff --git a/frontend/src/lib/models/__tests__/DashboardDetailModel.test.ts b/frontend/src/lib/models/__tests__/DashboardDetailModel.test.ts new file mode 100644 index 00000000..05090268 --- /dev/null +++ b/frontend/src/lib/models/__tests__/DashboardDetailModel.test.ts @@ -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 diff --git a/frontend/src/lib/models/__tests__/DashboardHubModel.test.ts b/frontend/src/lib/models/__tests__/DashboardHubModel.test.ts new file mode 100644 index 00000000..4658e1dc --- /dev/null +++ b/frontend/src/lib/models/__tests__/DashboardHubModel.test.ts @@ -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 diff --git a/frontend/src/lib/models/__tests__/DatasetDetailModel.test.ts b/frontend/src/lib/models/__tests__/DatasetDetailModel.test.ts new file mode 100644 index 00000000..572ddc43 --- /dev/null +++ b/frontend/src/lib/models/__tests__/DatasetDetailModel.test.ts @@ -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 diff --git a/frontend/src/lib/models/__tests__/DatasetsHubModel.test.ts b/frontend/src/lib/models/__tests__/DatasetsHubModel.test.ts new file mode 100644 index 00000000..8fd3f1f1 --- /dev/null +++ b/frontend/src/lib/models/__tests__/DatasetsHubModel.test.ts @@ -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 diff --git a/frontend/src/lib/models/__tests__/DictionaryDetailModel.test.ts b/frontend/src/lib/models/__tests__/DictionaryDetailModel.test.ts new file mode 100644 index 00000000..fa5a3ac2 --- /dev/null +++ b/frontend/src/lib/models/__tests__/DictionaryDetailModel.test.ts @@ -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 diff --git a/frontend/src/lib/models/__tests__/LLMReportModel.test.ts b/frontend/src/lib/models/__tests__/LLMReportModel.test.ts new file mode 100644 index 00000000..42ed99a1 --- /dev/null +++ b/frontend/src/lib/models/__tests__/LLMReportModel.test.ts @@ -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 diff --git a/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts b/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts new file mode 100644 index 00000000..757ff620 --- /dev/null +++ b/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts @@ -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 diff --git a/frontend/src/lib/models/__tests__/ValidationRunDetailModel.test.ts b/frontend/src/lib/models/__tests__/ValidationRunDetailModel.test.ts new file mode 100644 index 00000000..6f50a7b2 --- /dev/null +++ b/frontend/src/lib/models/__tests__/ValidationRunDetailModel.test.ts @@ -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 diff --git a/frontend/src/lib/models/__tests__/ValidationTasksListModel.test.ts b/frontend/src/lib/models/__tests__/ValidationTasksListModel.test.ts new file mode 100644 index 00000000..569ba9ed --- /dev/null +++ b/frontend/src/lib/models/__tests__/ValidationTasksListModel.test.ts @@ -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