Files
IdeA/frontend/src/features/git/git.test.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
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.
2026-09-11 21:53:56 +02:00

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);
});
});