// #region DashboardDetailModelTests [C:3] [TYPE Module] [SEMANTICS test,model,dashboard-detail] // @BRIEF L1 unit tests for DashboardDetailModel — no DOM render, mocks API + toast + i18n + navigation. // @RELATION BINDS_TO -> [Dashboards.DetailModel] // @TEST_INVARIANT: load-gate -> VERIFIED_BY: [test_load_guarded_by_context, test_load_detail_guarded] // @TEST_INVARIANT: thumbnail-cleanup -> VERIFIED_BY: [test_cleanup_releases_thumbnail, test_load_thumbnail_releases_previous] // @TEST_EDGE: missing_field -> dashboardRef not set on load // @TEST_EDGE: invalid_type -> API returns non-object // @TEST_EDGE: external_fail -> API throws on each fetch 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(), openDrawerForTask: vi.fn(), })); vi.mock("$lib/stores/environmentContext.svelte.js", () => ({ environmentContextStore: { value: { selectedEnvId: "" }, }, })); vi.mock("$lib/routes.js", () => ({ ROUTES: { dashboards: { list: vi.fn(() => "/dashboards"), detail: vi.fn(() => "/dashboards/1") }, datasets: { detail: vi.fn(() => "/datasets/1") }, }, })); vi.mock("$lib/i18n/index.svelte.js", () => ({ getT: () => ({ dashboard: { missing_context: "Missing context", missing_context_hint: "No environment selected. Choose an environment from the top bar and reload the page.", load_detail_failed: "Failed", thumbnail_generating: "Generating...", thumbnail_failed: "Thumbnail failed", backup_started: "Backup started", backup_task_failed: "Backup failed", backup: "Backup", llm_check: "LLM Check", }, }), })); // Mock URL.createObjectURL / revokeObjectURL const mockObjectUrl = "blob:mock-url-1"; URL.createObjectURL = vi.fn(() => mockObjectUrl); URL.revokeObjectURL = vi.fn(); import { DashboardDetailModel } from "../DashboardDetailModel.svelte.ts"; import { api } from "$lib/api.js"; import { fetchApi } from "$lib/api"; import { goto } from "$app/navigation"; import { addToast } from "$lib/toasts.svelte.js"; import { openDrawerForTaskIfPreferred } from "$lib/stores/taskDrawer.svelte.js"; describe("DashboardDetailModel — L1 invariants (no render)", () => { let model: DashboardDetailModel; beforeEach(() => { vi.clearAllMocks(); model = new DashboardDetailModel(); }); // ═══════════════════════════════════════════════════════════════ // Initial state // ═══════════════════════════════════════════════════════════════ describe("initial state", () => { it("starts with default values", () => { expect(model.screenState).toBe("idle"); expect(model.dashboard).toBeNull(); expect(model.error).toBeNull(); expect(model.dashboardRef).toBe(""); expect(model.envId).toBe(""); expect(model.activeTab).toBe("resources"); expect(model.taskHistory).toEqual([]); expect(model.isTaskHistoryLoading).toBe(false); expect(model.validationHistory).toEqual([]); expect(model.thumbnailUrl).toBe(""); expect(model.isStartingBackup).toBe(false); expect(model.showGitManager).toBe(false); }); it("derived isLoading equals screenState === 'loading'", () => { expect(model.isLoading).toBe(false); model.screenState = "loading"; expect(model.isLoading).toBe(true); }); it("derived gitDashboardRef falls through slug -> dashboardRef -> empty", () => { expect(model.gitDashboardRef).toBe(""); model.dashboardRef = "my-ref"; expect(model.gitDashboardRef).toBe("my-ref"); model.dashboard = { id: 1, title: "Sales", slug: "sales-dash" }; expect(model.gitDashboardRef).toBe("sales-dash"); }); it("derived resolvedDashboardId from numeric dashboardRef", () => { model.dashboardRef = "42"; expect(model.resolvedDashboardId).toBe(42); }); it("derived resolvedDashboardId from dashboard.id", () => { model.dashboard = { id: 99, title: "Sales" }; expect(model.resolvedDashboardId).toBe(99); }); it("derived resolvedDashboardId is null when ref is non-numeric and no dashboard", () => { model.dashboardRef = "slug-ref"; expect(model.resolvedDashboardId).toBeNull(); }); }); // ═══════════════════════════════════════════════════════════════ // @INVARIANT: loadDashboardDetail guarded by context // ═══════════════════════════════════════════════════════════════ describe("loadDashboardDetail", () => { it("sets error when dashboardRef is empty", async () => { model.envId = "env-1"; await model.loadDashboardDetail(); expect(model.screenState).toBe("error"); expect(model.error).toBeTruthy(); }); it("sets error when envId is empty", async () => { model.dashboardRef = "dash-1"; 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"); expect(model.error).toBeNull(); }); it("sets loading state before fetch, then loaded after", async () => { vi.mocked(api.getDashboardDetail).mockImplementation(() => new Promise((r) => setTimeout(r, 10))); model.dashboardRef = "sales-dash"; model.envId = "env-1"; const promise = model.loadDashboardDetail(); expect(model.screenState).toBe("loading"); await promise; expect(model.screenState).toBe("loaded"); }); it("sets error state on API failure", async () => { vi.mocked(api.getDashboardDetail).mockRejectedValue(new Error("404 Not Found")); model.dashboardRef = "sales-dash"; model.envId = "env-1"; await model.loadDashboardDetail(); expect(model.screenState).toBe("error"); expect(model.error).toBe("404 Not Found"); }); it("handles non-Error rejection gracefully", async () => { vi.mocked(api.getDashboardDetail).mockRejectedValue("string error"); model.dashboardRef = "sales-dash"; model.envId = "env-1"; await model.loadDashboardDetail(); expect(model.screenState).toBe("error"); }); // ── P0-1 fix: auto-populate envId from environmentContextStore ── it("auto-populates envId from store when URL param is missing", async () => { const { environmentContextStore } = await import("$lib/stores/environmentContext.svelte.js"); (environmentContextStore as any).value = { selectedEnvId: "ss-dev" }; vi.mocked(api.getDashboardDetail).mockResolvedValue({ id: 1, title: "Test" }); model.dashboardRef = "test-dash"; model.envId = ""; // missing from URL await model.loadDashboardDetail(); expect(model.screenState).toBe("loaded"); expect(model.envId).toBe("ss-dev"); expect(api.getDashboardDetail).toHaveBeenCalledWith("ss-dev", "test-dash"); }); it("does NOT override envId from URL when already present", async () => { const { environmentContextStore } = await import("$lib/stores/environmentContext.svelte.js"); (environmentContextStore as any).value = { selectedEnvId: "ss-dev" }; vi.mocked(api.getDashboardDetail).mockResolvedValue({ id: 2, title: "Test2" }); model.dashboardRef = "test-dash"; model.envId = "ss-prod"; // explicitly set from URL await model.loadDashboardDetail(); // envId from URL should take priority over store fallback expect(api.getDashboardDetail).toHaveBeenCalledWith("ss-prod", "test-dash"); }); it("shows helpful error hint when no envId from URL or store", async () => { const { environmentContextStore } = await import("$lib/stores/environmentContext.svelte.js"); (environmentContextStore as any).value = { selectedEnvId: "" }; model.dashboardRef = "test-dash"; model.envId = ""; await model.loadDashboardDetail(); expect(model.screenState).toBe("error"); // Error should contain the recovery hint from missing_context_hint expect(model.error).toBeTruthy(); expect(model.error).toEqual(expect.stringContaining("environment")); }); it("handles undefined store value gracefully", async () => { const { environmentContextStore } = await import("$lib/stores/environmentContext.svelte.js"); (environmentContextStore as any).value = undefined; vi.mocked(api.getDashboardDetail).mockResolvedValue({ id: 3, title: "Test3" }); model.dashboardRef = "test-dash"; model.envId = "ss-prod"; // envId from URL await model.loadDashboardDetail(); // Should still work with explicit envId expect(model.screenState).toBe("loaded"); }); }); // ═══════════════════════════════════════════════════════════════ // loadDashboardPage — parallel fetch // ═══════════════════════════════════════════════════════════════ describe("loadDashboardPage", () => { it("loads detail, task history, thumbnail, validation 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 }); vi.mocked(fetchApi).mockResolvedValue({ items: [] }); model.dashboardRef = "sales-dash"; model.envId = "env-1"; await model.loadDashboardPage(); expect(api.getDashboardDetail).toHaveBeenCalled(); expect(api.getDashboardTaskHistory).toHaveBeenCalled(); expect(api.getDashboardThumbnail).toHaveBeenCalled(); expect(fetchApi).toHaveBeenCalledWith(expect.stringContaining("/validation-tasks/runs/all")); }); }); // ═══════════════════════════════════════════════════════════════ // loadTaskHistory // ═══════════════════════════════════════════════════════════════ describe("loadTaskHistory", () => { it("returns early when ref is empty", async () => { await model.loadTaskHistory(); expect(api.getDashboardTaskHistory).not.toHaveBeenCalled(); }); it("loads task history on success", async () => { vi.mocked(api.getDashboardTaskHistory).mockResolvedValue({ items: [{ id: "task-1", status: "success" }], }); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadTaskHistory(); expect(api.getDashboardTaskHistory).toHaveBeenCalledWith("env-1", "dash-1", { limit: 30 }); expect(model.taskHistory).toHaveLength(1); expect(model.isTaskHistoryLoading).toBe(false); }); it("sets error on API failure", async () => { vi.mocked(api.getDashboardTaskHistory).mockRejectedValue(new Error("History failed")); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadTaskHistory(); expect(model.taskHistoryError).toBe("History failed"); expect(model.taskHistory).toEqual([]); expect(model.isTaskHistoryLoading).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════ // loadThumbnail // ═══════════════════════════════════════════════════════════════ describe("loadThumbnail", () => { it("returns early when ref is empty", async () => { await model.loadThumbnail(); expect(api.getDashboardThumbnail).not.toHaveBeenCalled(); }); it("loads thumbnail and creates blob URL", async () => { const fakeBlob = new Blob(["fake"], { type: "image/png" }); vi.mocked(api.getDashboardThumbnail).mockResolvedValue(fakeBlob); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadThumbnail(); expect(api.getDashboardThumbnail).toHaveBeenCalledWith("env-1", "dash-1", { force: false }); expect(URL.createObjectURL).toHaveBeenCalledWith(fakeBlob); expect(model.thumbnailUrl).toBe(mockObjectUrl); expect(model.isThumbnailLoading).toBe(false); }); it("handles 202 status as generating", async () => { vi.mocked(api.getDashboardThumbnail).mockRejectedValue({ status: 202 }); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadThumbnail(); expect(model.thumbnailError).toBe("Generating..."); expect(model.isThumbnailLoading).toBe(false); }); it("handles other errors", async () => { vi.mocked(api.getDashboardThumbnail).mockRejectedValue(new Error("Network")); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadThumbnail(); expect(model.thumbnailError).toBe("Network"); expect(model.isThumbnailLoading).toBe(false); }); // @INVARIANT: thumbnail blob URL released on reassignment it("releases previous thumbnail URL when loading new one", async () => { const fakeBlob = new Blob(["fake"], { type: "image/png" }); model.thumbnailUrl = "blob:old-url"; vi.mocked(api.getDashboardThumbnail).mockResolvedValue(fakeBlob); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadThumbnail(); expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:old-url"); }); }); // ═══════════════════════════════════════════════════════════════ // loadValidationHistory // ═══════════════════════════════════════════════════════════════ describe("loadValidationHistory", () => { it("returns early when ref is empty", async () => { await model.loadValidationHistory(); expect(fetchApi).not.toHaveBeenCalled(); }); it("loads validation history on success via items", async () => { vi.mocked(fetchApi).mockResolvedValue({ items: [{ id: "vr-1", status: "completed" }] }); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadValidationHistory(); expect(model.validationHistory).toHaveLength(1); expect(model.isValidationHistoryLoading).toBe(false); }); it("falls back to results field when items is absent", async () => { vi.mocked(fetchApi).mockResolvedValue({ results: [{ id: "vr-2" }] }); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadValidationHistory(); expect(model.validationHistory).toHaveLength(1); }); it("sets error on API failure", async () => { vi.mocked(fetchApi).mockRejectedValue(new Error("Val failed")); model.dashboardRef = "dash-1"; model.envId = "env-1"; await model.loadValidationHistory(); expect(model.validationHistoryError).toBe("Val failed"); expect(model.validationHistory).toEqual([]); expect(model.isValidationHistoryLoading).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════ // runBackupTask // ═══════════════════════════════════════════════════════════════ describe("runBackupTask", () => { it("returns early when isStartingBackup is true", async () => { model.isStartingBackup = true; await model.runBackupTask(); expect(api.postApi).not.toHaveBeenCalled(); }); it("returns early when envId is empty", async () => { await model.runBackupTask(); expect(api.postApi).not.toHaveBeenCalled(); }); it("returns early when resolvedDashboardId is null", async () => { model.envId = "env-1"; model.dashboardRef = "slug-only"; await model.runBackupTask(); expect(api.postApi).not.toHaveBeenCalled(); }); it("posts backup and shows success toast", async () => { model.envId = "env-1"; model.dashboard = { id: 1, title: "Sales" }; vi.mocked(api.postApi).mockResolvedValue({ task_id: "task-1" }); vi.mocked(api.getDashboardTaskHistory).mockResolvedValue({ items: [] }); await model.runBackupTask(); expect(api.postApi).toHaveBeenCalledWith("/dashboards/backup", { env_id: "env-1", dashboard_ids: [1], }); expect(openDrawerForTaskIfPreferred).toHaveBeenCalledWith("task-1"); expect(addToast).toHaveBeenCalledWith("Backup started", "success"); expect(model.isStartingBackup).toBe(false); }); it("shows error toast on API failure", async () => { model.envId = "env-1"; model.dashboard = { id: 1, title: "Sales" }; vi.mocked(api.postApi).mockRejectedValue(new Error("Backup failed")); await model.runBackupTask(); expect(addToast).toHaveBeenCalledWith("Backup failed", "error"); expect(model.isStartingBackup).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════ // Git-related actions // ═══════════════════════════════════════════════════════════════ describe("handleSyncAndOpenCommit", () => { it("opens git manager on sync success", async () => { model.gitModel.syncRepository = vi.fn().mockResolvedValue(true); await model.handleSyncAndOpenCommit(); expect(model.showGitManager).toBe(true); }); it("does not open git manager on sync failure", async () => { model.gitModel.syncRepository = vi.fn().mockResolvedValue(false); await model.handleSyncAndOpenCommit(); expect(model.showGitManager).toBe(false); }); }); describe("trackGitManagerClose", () => { it("sets wasOpen when showGitManager becomes true", () => { model.showGitManager = true; model.trackGitManagerClose(); // no assertion needed — internal flag set }); it("reloads status when git manager closes after having been open", () => { model.showGitManager = true; model.trackGitManagerClose(); // marks wasOpen model.showGitManager = false; model.gitModel.loadStatus = vi.fn().mockResolvedValue(undefined); model.trackGitManagerClose(); // triggers reload expect(model.gitModel.loadStatus).toHaveBeenCalled(); }); }); // ═══════════════════════════════════════════════════════════════ // cleanup // ═══════════════════════════════════════════════════════════════ // @INVARIANT: cleanup releases thumbnail it("cleanup releases thumbnail URL", () => { model.thumbnailUrl = "blob:some-url"; model.cleanup(); expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:some-url"); expect(model.thumbnailUrl).toBe(""); }); // ═══════════════════════════════════════════════════════════════ // Navigation // ═══════════════════════════════════════════════════════════════ describe("goBack", () => { it("navigates to dashboard list", () => { model.envId = "env-1"; model.goBack(); expect(goto).toHaveBeenCalled(); }); }); describe("openDataset", () => { it("navigates to dataset detail", () => { model.envId = "env-1"; model.openDataset("ds-42"); expect(goto).toHaveBeenCalled(); }); }); // ═══════════════════════════════════════════════════════════════ // Static utilities // ═══════════════════════════════════════════════════════════════ describe("static methods", () => { describe("toTaskTypeLabel", () => { it("returns backup label for superset-backup", () => { expect(DashboardDetailModel.toTaskTypeLabel("superset-backup")).toBe("Backup"); }); it("returns LLM check label for llm_dashboard_validation", () => { expect(DashboardDetailModel.toTaskTypeLabel("llm_dashboard_validation")).toBe("LLM Check"); }); it("returns pluginId as-is for unknown plugins", () => { expect(DashboardDetailModel.toTaskTypeLabel("custom-plugin")).toBe("custom-plugin"); }); it("returns dash for undefined", () => { expect(DashboardDetailModel.toTaskTypeLabel(undefined)).toBe("-"); }); }); describe("getTaskStatusClasses", () => { it("returns primary class for running/pending", () => { expect(DashboardDetailModel.getTaskStatusClasses("running")).toContain("bg-primary-light"); expect(DashboardDetailModel.getTaskStatusClasses("pending")).toContain("bg-primary-light"); expect(DashboardDetailModel.getTaskStatusClasses("PENDING")).toContain("bg-primary-light"); }); it("returns success class for success", () => { expect(DashboardDetailModel.getTaskStatusClasses("success")).toContain("bg-success-light"); }); it("returns destructive class for failed/error", () => { expect(DashboardDetailModel.getTaskStatusClasses("failed")).toContain("bg-destructive-light"); expect(DashboardDetailModel.getTaskStatusClasses("error")).toContain("bg-destructive-light"); }); it("returns warning class for awaiting_input", () => { expect(DashboardDetailModel.getTaskStatusClasses("awaiting_input")).toContain("bg-warning-light"); expect(DashboardDetailModel.getTaskStatusClasses("waiting_input")).toContain("bg-warning-light"); }); it("returns muted class for unknown status", () => { expect(DashboardDetailModel.getTaskStatusClasses("unknown")).toContain("bg-surface-muted"); }); it("handles undefined status", () => { expect(DashboardDetailModel.getTaskStatusClasses(undefined)).toContain("bg-surface-muted"); }); }); describe("getValidationStatus", () => { it("returns na for non-validation tasks", () => { expect(DashboardDetailModel.getValidationStatus({ plugin_id: "backup" }).level).toBe("na"); }); it("returns fail for FAIL status", () => { const r = DashboardDetailModel.getValidationStatus({ plugin_id: "llm_dashboard_validation", validation_status: "FAIL" }); expect(r.level).toBe("fail"); expect(r.label).toBe("FAIL"); }); it("returns warn for WARN status", () => { const r = DashboardDetailModel.getValidationStatus({ plugin_id: "llm_dashboard_validation", validation_status: "WARN" }); expect(r.level).toBe("warn"); }); it("returns pass for PASS status", () => { const r = DashboardDetailModel.getValidationStatus({ plugin_id: "llm_dashboard_validation", validation_status: "PASS" }); expect(r.level).toBe("pass"); expect(r.icon).toBe("OK"); }); it("returns unknown for unrecognized status", () => { const r = DashboardDetailModel.getValidationStatus({ plugin_id: "llm_dashboard_validation", validation_status: "ERROR" }); expect(r.level).toBe("unknown"); }); it("handles missing validation_status", () => { const r = DashboardDetailModel.getValidationStatus({ plugin_id: "llm_dashboard_validation" }); expect(r.level).toBe("unknown"); }); }); describe("getValidationStatusClasses", () => { it("returns destructive for fail", () => { expect(DashboardDetailModel.getValidationStatusClasses("fail")).toContain("bg-destructive-light"); }); it("returns warning for warn", () => { expect(DashboardDetailModel.getValidationStatusClasses("warn")).toContain("bg-warning-light"); }); it("returns success for pass", () => { expect(DashboardDetailModel.getValidationStatusClasses("pass")).toContain("bg-success-light"); }); it("returns muted for unknown", () => { expect(DashboardDetailModel.getValidationStatusClasses("unknown")).toContain("bg-surface-muted"); }); it("returns page bg for na", () => { expect(DashboardDetailModel.getValidationStatusClasses("na")).toContain("bg-surface-page"); }); }); describe("formatDate", () => { it("returns dash for falsy input", () => { expect(DashboardDetailModel.formatDate(null)).toBe("-"); expect(DashboardDetailModel.formatDate(undefined)).toBe("-"); expect(DashboardDetailModel.formatDate("")).toBe("-"); }); it("returns formatted date for valid input", () => { const result = DashboardDetailModel.formatDate("2024-06-10T12:30:00Z"); expect(result).toBeTruthy(); expect(result).not.toBe("-"); }); it("returns dash for invalid date string", () => { expect(DashboardDetailModel.formatDate("not-a-date")).toBe("-"); }); }); }); }); // #endregion DashboardDetailModelTests