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.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -18,6 +18,7 @@ import {
WebSession,
} from "@/adapters/http";
import { WebApp } from "./WebApp";
import { I18nProvider } from "@/i18n";
afterEach(() => setWebLiveClient(null));
@ -53,7 +54,7 @@ function renderPaired(gateways: Gateways) {
session.markPaired();
return render(
<DIProvider gateways={gateways}>
<WebApp session={session} />
<I18nProvider><WebApp session={session} /></I18nProvider>
</DIProvider>,
);
}
@ -93,7 +94,7 @@ describe("WebWorkspace live surfaces (F5)", () => {
fireEvent.click(await screen.findByText("Demo"));
const panel = await screen.findByTestId("web-workstate");
expect(panel.textContent).toContain("Idle");
expect(panel.textContent).toContain("Inactif");
// The agent goes busy server-side; the read-model now reflects it…
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
@ -106,24 +107,24 @@ describe("WebWorkspace live surfaces (F5)", () => {
});
await waitFor(() =>
expect(screen.getByTestId("web-workstate").textContent).toContain("Busy"),
expect(screen.getByTestId("web-workstate").textContent).toContain("Occupé"),
);
});
it("renders background tasks and wires cancel through the gateway", async () => {
it("tâches de fond de renders and wires cancel through the gateway", async () => {
const task = backgroundTask("bt-1", "a1");
const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }]));
const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask");
await openLivePanel(gateways);
fireEvent.click(
await screen.findByRole("button", { name: "Afficher les background tasks de Archi" }),
await screen.findByRole("button", { name: "Afficher les tâches de fond de Archi" }),
);
const tasks = await screen.findByLabelText("Archi background tasks");
expect(tasks.textContent).toContain("Running");
const tasks = await screen.findByLabelText("Tâches de fond de Archi");
expect(tasks.textContent).toContain("En cours");
expect(tasks.textContent).toContain("shell");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
fireEvent.click(screen.getByRole("button", { name: "Annuler" }));
await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1"));
});
@ -135,33 +136,33 @@ describe("WebWorkspace live surfaces (F5)", () => {
await openLivePanel(gateways);
const archiToggle = await screen.findByRole("button", {
name: "Afficher les background tasks de Archi",
name: "Afficher les tâches de fond de Archi",
});
const frontToggle = await screen.findByRole("button", {
name: "Afficher les background tasks de Front",
name: "Afficher les tâches de fond de Front",
});
expect(archiToggle.getAttribute("aria-expanded")).toBe("false");
expect(frontToggle.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
expect(screen.queryByLabelText("Front background tasks")).toBeNull();
expect(screen.queryByLabelText("Tâches de fond de Archi")).toBeNull();
expect(screen.queryByLabelText("Tâches de fond de Front")).toBeNull();
fireEvent.click(archiToggle);
expect(
screen
.getByRole("button", { name: "Masquer les background tasks de Archi" })
.getByRole("button", { name: "Masquer les tâches de fond de Archi" })
.getAttribute("aria-expanded"),
).toBe("true");
expect(
screen
.getByRole("button", { name: "Afficher les background tasks de Front" })
.getByRole("button", { name: "Afficher les tâches de fond de Front" })
.getAttribute("aria-expanded"),
).toBe("false");
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
expect(screen.queryByLabelText("Front background tasks")).toBeNull();
expect(await screen.findByLabelText("Tâches de fond de Archi")).toBeTruthy();
expect(screen.queryByLabelText("Tâches de fond de Front")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Front" }));
expect(screen.getByLabelText("Archi background tasks")).toBeTruthy();
expect(screen.getByLabelText("Front background tasks")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Afficher les tâches de fond de Front" }));
expect(screen.getByLabelText("Tâches de fond de Archi")).toBeTruthy();
expect(screen.getByLabelText("Tâches de fond de Front")).toBeTruthy();
});
it("updates task counters without changing the current expanded state", async () => {
@ -171,11 +172,11 @@ describe("WebWorkspace live surfaces (F5)", () => {
await openLivePanel(gateways);
const collapsedToggle = await screen.findByRole("button", {
name: "Afficher les background tasks de Archi",
name: "Afficher les tâches de fond de Archi",
});
expect(collapsedToggle.getAttribute("aria-expanded")).toBe("false");
expect(collapsedToggle.textContent).toContain("1");
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
expect(screen.queryByLabelText("Tâches de fond de Archi")).toBeNull();
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
@ -198,18 +199,18 @@ describe("WebWorkspace live surfaces (F5)", () => {
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Afficher les background tasks de Archi" }).textContent,
screen.getByRole("button", { name: "Afficher les tâches de fond de Archi" }).textContent,
).toContain("2"),
);
expect(
screen
.getByRole("button", { name: "Afficher les background tasks de Archi" })
.getByRole("button", { name: "Afficher les tâches de fond de Archi" })
.getAttribute("aria-expanded"),
).toBe("false");
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
expect(screen.queryByLabelText("Tâches de fond de Archi")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Archi" }));
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Afficher les tâches de fond de Archi" }));
expect(await screen.findByLabelText("Tâches de fond de Archi")).toBeTruthy();
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
@ -227,15 +228,15 @@ describe("WebWorkspace live surfaces (F5)", () => {
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Masquer les background tasks de Archi" }).textContent,
screen.getByRole("button", { name: "Masquer les tâches de fond de Archi" }).textContent,
).toContain("1"),
);
expect(
screen
.getByRole("button", { name: "Masquer les background tasks de Archi" })
.getByRole("button", { name: "Masquer les tâches de fond de Archi" })
.getAttribute("aria-expanded"),
).toBe("true");
expect(screen.getByLabelText("Archi background tasks")).toBeTruthy();
expect(screen.getByLabelText("Tâches de fond de Archi")).toBeTruthy();
});
it("hides and resets an open background task group when its task count reaches zero", async () => {
@ -244,9 +245,9 @@ describe("WebWorkspace live surfaces (F5)", () => {
]));
await openLivePanel(gateways);
fireEvent.click(await screen.findByRole("button", {
name: "Afficher les background tasks de Archi",
name: "Afficher les tâches de fond de Archi",
}));
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
expect(await screen.findByLabelText("Tâches de fond de Archi")).toBeTruthy();
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
@ -263,9 +264,9 @@ describe("WebWorkspace live surfaces (F5)", () => {
});
await waitFor(() =>
expect(screen.queryByRole("button", { name: /background tasks de Archi/ })).toBeNull(),
expect(screen.queryByRole("button", { name: /tâches de fond de Archi/ })).toBeNull(),
);
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
expect(screen.queryByLabelText("Tâches de fond de Archi")).toBeNull();
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
@ -284,11 +285,11 @@ describe("WebWorkspace live surfaces (F5)", () => {
await waitFor(() =>
expect(
screen
.getByRole("button", { name: "Afficher les background tasks de Archi" })
.getByRole("button", { name: "Afficher les tâches de fond de Archi" })
.getAttribute("aria-expanded"),
).toBe("false"),
);
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
expect(screen.queryByLabelText("Tâches de fond de Archi")).toBeNull();
});
it("re-synchronises the read-model when the WS reconnects", async () => {