// frontend/src/lib/models/__tests__/CommitModel.test.ts // #region CommitModelTests [C:3] [TYPE Module] [SEMANTICS test,model,commit] // @BRIEF L1 unit tests for CommitModel — no DOM render, mocks API + toast + i18n. // @RELATION BINDS_TO -> [CommitModel] import { describe, it, expect, vi, beforeEach } from "vitest"; // ── Mocks ──────────────────────────────────────────────────────── vi.mock("../../../services/gitService.js", () => ({ gitService: { getStatus: vi.fn(), getDiff: vi.fn(), commit: vi.fn(), push: vi.fn(), }, })); vi.mock("$lib/api.js", () => ({ api: { postApi: vi.fn(), }, })); vi.mock('$lib/toasts.svelte.js', () => ({ addToast: vi.fn(), })); vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { subscribe: vi.fn() }, getT: vi.fn(() => ({})), _: vi.fn(), getT: vi.fn(() => ({})), })); vi.mock("svelte/store", () => ({ get: vi.fn(() => ({ git: {} })), })); import { CommitModel } from "../CommitModel.svelte.ts"; import { gitService } from "../../../services/gitService.js"; import { api } from "$lib/api.js"; // ── Types ───────────────────────────────────────────────────────── interface StatusFixture { is_dirty: boolean; current_branch: string; staged_files: string[]; modified_files: string[]; untracked_files: string[]; [key: string]: unknown; } // ── Helpers ────────────────────────────────────────────────────── /** Create a model with default constructor values. */ function createModel(overrides: Partial<{ dashboardId: string; envId: string | null; show: boolean }> = {}): CommitModel { return new CommitModel({ dashboardId: "test-dashboard", envId: "env-123", show: true, ...overrides, }); } /** Minimal git status payload. */ function makeStatus(overrides: Partial = {}): StatusFixture { return { is_dirty: true, current_branch: "main", staged_files: ["file1.py"], modified_files: [], untracked_files: [], ...overrides, }; } // ── Suite ──────────────────────────────────────────────────────── describe("CommitModel — L1 invariants (no render)", () => { let model: CommitModel; beforeEach(() => { vi.clearAllMocks(); model = createModel(); }); // ═══════════════════════════════════════════════════════════════ // @INVARIANT: loadStatus calls gitService // ═══════════════════════════════════════════════════════════════ describe("loadStatus", () => { it("fetches status and diff via gitService", async () => { const status = makeStatus(); vi.mocked(gitService.getStatus).mockResolvedValueOnce(status); vi.mocked(gitService.getDiff).mockResolvedValueOnce("staged diff content"); vi.mocked(gitService.getDiff).mockResolvedValueOnce("unstaged diff content"); await model.loadStatus(); expect(gitService.getStatus).toHaveBeenCalledWith("test-dashboard", "env-123"); expect(gitService.getDiff).toHaveBeenCalledWith("test-dashboard", null, true, "env-123"); expect(gitService.getDiff).toHaveBeenCalledWith("test-dashboard", null, false, "env-123"); expect(model.status).toEqual(status); expect(model.loading).toBe(false); }); it("combines staged and unstaged diff", async () => { vi.mocked(gitService.getStatus).mockResolvedValueOnce(makeStatus()); vi.mocked(gitService.getDiff).mockResolvedValueOnce("staged content"); vi.mocked(gitService.getDiff).mockResolvedValueOnce("unstaged content"); await model.loadStatus(); expect(model.diff).toContain("STAGED CHANGES"); expect(model.diff).toContain("UNSTAGED CHANGES"); expect(model.diff).toContain("staged content"); expect(model.diff).toContain("unstaged content"); }); it("sets empty diff when there are no changes", async () => { vi.mocked(gitService.getStatus).mockResolvedValueOnce(makeStatus()); vi.mocked(gitService.getDiff).mockResolvedValueOnce(null); vi.mocked(gitService.getDiff).mockResolvedValueOnce(null); await model.loadStatus(); expect(model.diff).toBe(""); }); it("sets error on API failure", async () => { vi.mocked(gitService.getStatus).mockRejectedValueOnce(new Error("Network error")); await model.loadStatus(); expect(model.status).toBeNull(); expect(model.diff).toBe(""); expect(model.loading).toBe(false); }); it("does nothing when dashboardId is empty", async () => { model.dashboardId = ""; await model.loadStatus(); expect(gitService.getStatus).not.toHaveBeenCalled(); }); it("does nothing when show is false", async () => { model.show = false; await model.loadStatus(); expect(gitService.getStatus).not.toHaveBeenCalled(); }); }); // ═══════════════════════════════════════════════════════════════ // @INVARIANT: loading lifecycle // ═══════════════════════════════════════════════════════════════ describe("loading lifecycle", () => { it("loadStatus sets loading true then false", async () => { vi.mocked(gitService.getStatus).mockImplementation(() => new Promise((r) => setTimeout(r, 10))); vi.mocked(gitService.getDiff).mockResolvedValueOnce(""); vi.mocked(gitService.getDiff).mockResolvedValueOnce(""); const promise = model.loadStatus(); expect(model.loading).toBe(true); await promise; expect(model.loading).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════ // @INVARIANT: handleGenerateMessage // ═══════════════════════════════════════════════════════════════ describe("handleGenerateMessage", () => { it("calls api.postApi and sets message", async () => { vi.mocked(api.postApi).mockResolvedValueOnce({ message: "AI generated commit message" }); await model.handleGenerateMessage(); expect(api.postApi).toHaveBeenCalledWith( expect.stringContaining("/git/repositories/test-dashboard/generate-message"), undefined, { suppressToast: true }, ); expect(model.message).toBe("AI generated commit message"); expect(model.generatingMessage).toBe(false); }); it("sets error on API failure", async () => { vi.mocked(api.postApi).mockRejectedValueOnce(new Error("Generation failed")); await model.handleGenerateMessage(); expect(model.generatingMessage).toBe(false); }); it("appends env_id when envId is set", async () => { vi.mocked(api.postApi).mockResolvedValueOnce({ message: "msg" }); await model.handleGenerateMessage(); expect(api.postApi).toHaveBeenCalledWith( expect.stringContaining("env_id=env-123"), undefined, { suppressToast: true }, ); }); }); // ═══════════════════════════════════════════════════════════════ // @INVARIANT: committing lifecycle // ═══════════════════════════════════════════════════════════════ describe("committing lifecycle", () => { it("sets committing true then false", async () => { model.message = "fix: test"; model.status = makeStatus(); vi.mocked(gitService.commit).mockImplementation(() => new Promise((r) => setTimeout(r, 10))); const promise = model.handleCommit(); expect(model.committing).toBe(true); await promise; expect(model.committing).toBe(false); }); it("resets committing on failure", async () => { model.message = "fix: test"; model.status = makeStatus(); vi.mocked(gitService.commit).mockRejectedValueOnce(new Error("Commit rejected")); await model.handleCommit(); expect(model.committing).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════ // @INVARIANT: handleCommit calls gitService.commit // ═══════════════════════════════════════════════════════════════ describe("handleCommit — API calls", () => { it("calls gitService.commit with correct args", async () => { model.message = "fix: resolve issue"; model.status = makeStatus(); model.autoPushAfterCommit = false; vi.mocked(gitService.commit).mockResolvedValueOnce({}); await model.handleCommit(); expect(gitService.commit).toHaveBeenCalledWith( "test-dashboard", "fix: resolve issue", [], "env-123", ); }); it("calls push when autoPushAfterCommit is true", async () => { model.message = "fix: auto-push"; model.status = makeStatus(); model.autoPushAfterCommit = true; vi.mocked(gitService.commit).mockResolvedValueOnce({}); vi.mocked(gitService.push).mockResolvedValueOnce({}); await model.handleCommit(); expect(gitService.push).toHaveBeenCalledWith("test-dashboard", "env-123"); }); it("does not call push when autoPushAfterCommit is false", async () => { model.message = "fix: no-push"; model.status = makeStatus(); model.autoPushAfterCommit = false; vi.mocked(gitService.commit).mockResolvedValueOnce({}); await model.handleCommit(); expect(gitService.push).not.toHaveBeenCalled(); }); it("clears message and closes modal on success", async () => { model.message = "fix: done"; model.status = makeStatus(); model.autoPushAfterCommit = false; vi.mocked(gitService.commit).mockResolvedValueOnce({}); await model.handleCommit(); expect(model.message).toBe(""); expect(model.show).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════ // @INVARIANT: canCommit derived // ═══════════════════════════════════════════════════════════════ describe("canCommit derived", () => { it("is false when loading", () => { model.loading = true; model.message = "fix"; model.status = makeStatus(); expect(model.canCommit).toBe(false); }); it("is false when committing", () => { model.committing = true; model.message = "fix"; model.status = makeStatus(); expect(model.canCommit).toBe(false); }); it("is false when message is empty", () => { model.message = ""; model.status = makeStatus(); expect(model.canCommit).toBe(false); }); it("is false when status is null", () => { model.message = "fix"; model.status = null; expect(model.canCommit).toBe(false); }); it("is false when there are no changes", () => { model.message = "fix"; model.status = makeStatus({ is_dirty: false, staged_files: [] }); expect(model.canCommit).toBe(false); }); it("is true when ready to commit", () => { model.message = "fix: ready"; model.status = makeStatus(); expect(model.canCommit).toBe(true); }); }); }); // #endregion CommitModelTests