Lot en un seul commit: les deux features partagent les mêmes contrats (domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes surfaces UI — la séparation par fichiers était impossible sans chirurgie de hunks risquant des états intermédiaires non compilables (convention lot des batches #310-#313 et #315). #325 — icône configurable par projet, watermark CLI custom: - domaine: ProjectIconSet (devicon, simple-icons, lucide, idea), ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon optionnel sur ProjectSettings; sérialisation camelCase et try_from strict - application: UpdateProjectSettings.execute accepte un patch theme_color OU icon (absent = inchangé, null = défaut produit), tests in-dependants avec store mémoire - backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto avec icon optionnel (désérialisation additive), commandes update_project_settings alignées Tauri/HTTP, tests DTO et HTTP (défaut null, set, clear, nom invalide rejeté) - frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en grille multi-lignes dans Paramètres projet, affichage de l'icône dans ProjectTabs et watermark grand format à faible opacité en fond de CLI custom; ports ProjectSettings.icon, Partial<ProjectSettings>, adapters tauri/mock/http et tests alignés #326 — localisation FR/EN et traduction des textes IdeA: - domaine: IdeaLanguage (fr | en), champ language optionnel sur IdeaUserSettings (absence = défaut produit, série additive validée) - backend/web-server: UserSettingsDto.language, update_user_settings accepte language, tests HTTP alignés; fixtures structured_launch_d3 mises à jour - frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression des sources visibles), GeneralSettings avec sélecteur de langue dans les paramètres, setupI18n dans vitest; migration de l'ensemble des textes hardcodés vers le catalogue sur toutes les surfaces (agents, layout, projets, tickets, sprints, web, plugins, permissions, git, templates, skills, mémoire, model servers, embedder, first-run, devices, scheduled tasks, workstate, dialogs), FR conservé par défaut; recherche d'icônes #325 en anglais conformément au ticket QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
285 lines
9.2 KiB
TypeScript
285 lines
9.2 KiB
TypeScript
/**
|
|
* L8 — git feature wired to the stateful `MockGitGateway` via the real
|
|
* `DIProvider`.
|
|
*
|
|
* Covers:
|
|
* - status shows staged/unstaged groupings
|
|
* - indexer moves a file from Unstaged to Staged
|
|
* - commit with empty message is blocked (button disabled)
|
|
* - commit with message but nothing staged is blocked (button disabled)
|
|
* - commit moves staged files out and adds an entry to the log
|
|
* - checkout changes the current branch
|
|
* - MockGitGateway unit behaviour
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|
|
|
import { MockGitGateway } from "@/adapters/mock";
|
|
import type { Gateways } from "@/ports";
|
|
import { DIProvider } from "@/app/di";
|
|
import { GitPanel } from "./GitPanel";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PROJECT_ID = "proj-git-test-001";
|
|
|
|
function renderPanel(git: MockGitGateway = new MockGitGateway()) {
|
|
const gateways = { git } as unknown as Gateways;
|
|
return {
|
|
git,
|
|
...render(
|
|
<DIProvider gateways={gateways}>
|
|
<GitPanel projectId={PROJECT_ID} />
|
|
</DIProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
/** Waits until the "Changes" section is rendered (panel stabilised). */
|
|
async function waitForPanel() {
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("region", { name: "Modifications" })).toBeTruthy();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration tests (GitPanel + MockGitGateway via DIProvider)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("GitPanel (with MockGitGateway)", () => {
|
|
it("shows demo files split between Staged and Unstaged sections", async () => {
|
|
const git = new MockGitGateway();
|
|
// Seed a staged file.
|
|
await git.init(PROJECT_ID);
|
|
await git.stage(PROJECT_ID, "src/main.rs");
|
|
|
|
renderPanel(git);
|
|
await waitForPanel();
|
|
|
|
await screen.findByText("Indexées");
|
|
await screen.findByText("Non indexées");
|
|
// src/main.rs is staged → Unindexer button exists
|
|
expect(
|
|
screen.getByRole("button", { name: "désindexer src/main.rs" }),
|
|
).toBeTruthy();
|
|
// README.md is unstaged → Stage button exists
|
|
expect(
|
|
screen.getByRole("button", { name: "indexer README.md" }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("clicking Stage moves a file to the Staged group", async () => {
|
|
renderPanel();
|
|
await waitForPanel();
|
|
|
|
// Stage README.md
|
|
const stageBtn = await screen.findByRole("button", {
|
|
name: "indexer README.md",
|
|
});
|
|
fireEvent.click(stageBtn);
|
|
|
|
// After staging, an Unindexer button should appear for README.md
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: "désindexer README.md" }),
|
|
).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("clicking Unindexer moves a file back to Unstaged", async () => {
|
|
const git = new MockGitGateway();
|
|
await git.init(PROJECT_ID);
|
|
await git.stage(PROJECT_ID, "src/main.rs");
|
|
|
|
renderPanel(git);
|
|
await waitForPanel();
|
|
|
|
const unstageBtn = await screen.findByRole("button", {
|
|
name: "désindexer src/main.rs",
|
|
});
|
|
fireEvent.click(unstageBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: "indexer src/main.rs" }),
|
|
).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("Commit button is disabled when message is empty", async () => {
|
|
renderPanel();
|
|
await waitForPanel();
|
|
|
|
const btn = screen.getByRole("button", { name: /commit/i });
|
|
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
|
|
it("Commit button is disabled when message is set but nothing is staged", async () => {
|
|
renderPanel();
|
|
await waitForPanel();
|
|
|
|
fireEvent.change(screen.getByLabelText("message de commit"), {
|
|
target: { value: "my message" },
|
|
});
|
|
|
|
const btn = screen.getByRole("button", { name: /commit/i });
|
|
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
|
|
it("successful commit removes staged files and adds to the log", async () => {
|
|
const git = new MockGitGateway();
|
|
await git.init(PROJECT_ID);
|
|
await git.stage(PROJECT_ID, "src/main.rs");
|
|
await git.stage(PROJECT_ID, "README.md");
|
|
|
|
renderPanel(git);
|
|
await waitForPanel();
|
|
|
|
fireEvent.change(screen.getByLabelText("message de commit"), {
|
|
target: { value: "initial commit" },
|
|
});
|
|
|
|
const btn = screen.getByRole("button", { name: /commit/i });
|
|
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
|
fireEvent.click(btn);
|
|
|
|
// After commit, no files should remain (both were staged).
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Aucune modification.")).toBeTruthy();
|
|
});
|
|
|
|
// The commit should appear in the log.
|
|
await waitFor(() => {
|
|
expect(screen.getByText("initial commit")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("checkout changes the current branch label", async () => {
|
|
renderPanel();
|
|
await waitForPanel();
|
|
|
|
// Ensure the Branches section is rendered with "main" as current.
|
|
const branchesSection = await screen.findByRole("region", {
|
|
name: "Branches",
|
|
});
|
|
expect(branchesSection).toBeTruthy();
|
|
|
|
// Create & checkout a new branch via the form.
|
|
const branchInput = screen.getByLabelText("nom de la nouvelle branche");
|
|
fireEvent.change(branchInput, { target: { value: "feature/new-thing" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /créer.*basculer/i }));
|
|
|
|
// After checkout, the new branch should appear (may show in current + list).
|
|
await waitFor(() => {
|
|
const matches = screen.getAllByText("feature/new-thing");
|
|
expect(matches.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// A checkout button for "main" should now appear (since it's no longer current).
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: "basculer sur main" }),
|
|
).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("shows 'No commits yet.' in the Log section initially", async () => {
|
|
renderPanel();
|
|
await waitForPanel();
|
|
|
|
expect(screen.getByText("Aucun commit.")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MockGitGateway unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MockGitGateway (unit)", () => {
|
|
it("status returns seeded files for a new project", async () => {
|
|
const gw = new MockGitGateway();
|
|
const files = await gw.status("proj");
|
|
expect(files.length).toBeGreaterThan(0);
|
|
expect(files.every((f) => !f.staged)).toBe(true);
|
|
});
|
|
|
|
it("init creates state if absent", async () => {
|
|
const gw = new MockGitGateway();
|
|
await gw.init("new-proj");
|
|
const files = await gw.status("new-proj");
|
|
expect(Array.isArray(files)).toBe(true);
|
|
});
|
|
|
|
it("indexer toggles a file to staged=true", async () => {
|
|
const gw = new MockGitGateway();
|
|
const before = await gw.status("p");
|
|
const target = before[0].path;
|
|
await gw.stage("p", target);
|
|
const after = await gw.status("p");
|
|
const file = after.find((f) => f.path === target)!;
|
|
expect(file.staged).toBe(true);
|
|
});
|
|
|
|
it("désindexer toggles a file back to staged=false", async () => {
|
|
const gw = new MockGitGateway();
|
|
const files = await gw.status("p");
|
|
const target = files[0].path;
|
|
await gw.stage("p", target);
|
|
await gw.unstage("p", target);
|
|
const after = await gw.status("p");
|
|
expect(after.find((f) => f.path === target)!.staged).toBe(false);
|
|
});
|
|
|
|
it("commit removes staged files from status and adds to log", async () => {
|
|
const gw = new MockGitGateway();
|
|
const files = await gw.status("p");
|
|
const target = files[0].path;
|
|
await gw.stage("p", target);
|
|
const result = await gw.commit("p", "test commit\n\nmore text");
|
|
expect(result.summary).toBe("test commit");
|
|
expect(result.hash).toMatch(/^mock-/);
|
|
const after = await gw.status("p");
|
|
expect(after.find((f) => f.path === target)).toBeUndefined();
|
|
const log = await gw.log("p", 10);
|
|
expect(log[0].summary).toBe("test commit");
|
|
});
|
|
|
|
it("branches returns initial ['main'] with current='main'", async () => {
|
|
const gw = new MockGitGateway();
|
|
const b = await gw.branches("p");
|
|
expect(b.branches).toEqual(["main"]);
|
|
expect(b.current).toBe("main");
|
|
});
|
|
|
|
it("checkout creates a new branch and sets it as current", async () => {
|
|
const gw = new MockGitGateway();
|
|
await gw.checkout("p", "develop");
|
|
const b = await gw.branches("p");
|
|
expect(b.branches).toContain("develop");
|
|
expect(b.current).toBe("develop");
|
|
});
|
|
|
|
it("log(limit) returns at most limit entries", async () => {
|
|
const gw = new MockGitGateway();
|
|
const files = await gw.status("p");
|
|
// Stage and commit each file individually.
|
|
for (let i = 0; i < files.length; i++) {
|
|
await gw.stage("p", files[i].path);
|
|
await gw.commit("p", `commit ${i}`);
|
|
}
|
|
const log = await gw.log("p", 1);
|
|
expect(log).toHaveLength(1);
|
|
});
|
|
|
|
it("projects are isolated", async () => {
|
|
const gw = new MockGitGateway();
|
|
await gw.stage("proj-A", (await gw.status("proj-A"))[0].path);
|
|
await gw.commit("proj-A", "A commit");
|
|
const logB = await gw.log("proj-B", 10);
|
|
expect(logB).toHaveLength(0);
|
|
});
|
|
});
|