## Backend: agent module GRACE-Poly compliance - Split app.py (749→~280 lines) into _tool_resolver, _confirmation, _persistence - All 18 naked functions wrapped in #region/#endregion contracts - Fixed @DEFGROUP→@defgroup typos; added @DATA_CONTRACT, @SIDE_EFFECT, CoT logs - Conversation list API: added last_role, has_tool_calls, has_error, risk_level fields - Message state detection: Russian/English error patterns (недоступен, unavailable) - State field preserved in save_conversation messages - HITL titles: descriptive tool names instead of generic "HITL resume" ## Backend: conversation title generation (two-layer) - Layer 1: clean_title() — rule-based, strips file markers, pre-fetch blocks, JSON/CSV, URLs, code; truncates at 80 chars word boundary (25 unit tests, all edge cases) - Layer 2: generate_llm_title() — async best-effort LLM titling via /v1/chat/completions with per-conversation lock, graceful degradation on failure ## Frontend: conversation list indicators (orthogonal system) - Status dot (green/yellow/red/blue) per conversation state - Icon column: tool activity, errors, waiting, completed - Risk stripe (left border accent) + message count badge + relative time - Fixed group labels: "Сегодня"/"Вчера" instead of "3 ч"/"5 ч" - Hide "Окружение: —" when env is empty ## Frontend: guardrails card verification + fixes - Confirmed all interaction modes: Enter/click confirm, Escape/click deny - Auto-populate envId from environmentContextStore in DashboardDetailModel - Better error message: missing_context_hint with recovery guidance ## Design system: semantic tokens - Added category-* gradient tokens to tailwind.config.js - Sidebar + Breadcrumbs use semantic tokens (10 categories) - Raw Tailwind reduced from ~50 to 6 occurrences - Added skip-to-content link in root layout (+layout.svelte) - Added aria-label on DashboardDataGrid row checkboxes ## Protocol: INV_7 pragmatic exception - Modules may exceed 400 lines when contract-dense (every function has #region) - Recorded in semantics-core SKILL.md with rationale Total: 5841+ contracts, 2993+ edges, backend 41/41, frontend 2501/2501
639 lines
27 KiB
TypeScript
639 lines
27 KiB
TypeScript
// #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
|