- Delete legacy .ts stores (auth, activity, assistantChat, datasetReview, environmentContext, health, sidebar, taskDrawer, translationRun) - Create new .svelte.ts runes-based stores using reactive primitives - Migrate i18n: /i18n → /i18n/index.svelte.js - Migrate toasts: /toasts → /toasts.svelte.js - Update all component imports across 180+ files: components, pages, routes, lib - Remove fromStore() wrappers — use store.value directly with Svelte 5 runes - Update test mocks for new import paths - Add @DEPRECATED annotation to legacy alias
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
// 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> = {}): 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
|