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

@ -48,23 +48,23 @@ async function openAgentDetails(agentName: string): Promise<HTMLElement> {
const name = await screen.findByText(agentName);
const row = name.closest("li") as HTMLElement | null;
expect(row).toBeTruthy();
const button = within(row!).queryByRole("button", { name: "Details" });
const button = within(row!).queryByRole("button", { name: "Détails" });
if (button) fireEvent.click(button);
return row!;
}
async function openBackgroundTasks(agentName: string): Promise<HTMLElement> {
const row = await openAgentDetails(agentName);
const toggle = within(row).getByRole("button", { name: /background task/i });
const toggle = within(row).getByRole("button", { name: /tâche.*de fond/i });
fireEvent.click(toggle);
return await within(row).findByLabelText(`${agentName} background tasks`);
return await within(row).findByLabelText(`Tâches de fond de ${agentName}`);
}
describe("ProjectWorkStatePanel", () => {
it("renders the empty state", async () => {
renderPanel();
expect(await screen.findByText("No agent work state.")).toBeTruthy();
expect(await screen.findByText("Aucun état de travail d'agent.")).toBeTruthy();
});
it("renders an idle offline agent", async () => {
@ -83,9 +83,9 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Planner")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.queryByText("Idle")).toBeNull();
expect(screen.queryByLabelText("Planner tickets")).toBeNull();
expect(screen.getByText("Hors ligne")).toBeTruthy();
expect(screen.queryByText("Inactif")).toBeNull();
expect(screen.queryByLabelText("Tickets de Planner")).toBeNull();
});
it("renders a live busy agent with a short ticket", async () => {
@ -113,7 +113,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Builder")).toBeTruthy();
expect(screen.getByText("Busy")).toBeTruthy();
expect(screen.getByText("Occupé")).toBeTruthy();
expect(screen.getByText("Ticket 4c65d981")).toBeTruthy();
});
@ -150,8 +150,8 @@ describe("ProjectWorkStatePanel", () => {
const stopped = within(childRow).getByText("Arrêté");
expect(stopping.className).not.toContain("text-warning");
expect(stopped.className).not.toContain("text-warning");
expect(within(mainRow).queryByText("Busy")).toBeNull();
expect(within(childRow).queryByText("Busy")).toBeNull();
expect(within(mainRow).queryByText("Occupé")).toBeNull();
expect(within(childRow).queryByText("Occupé")).toBeNull();
});
it("renders an in-progress human ticket", async () => {
@ -186,14 +186,14 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Wire the workstate queue UI")).toBeTruthy();
expect(screen.queryByLabelText("Implementer tickets")).toBeNull();
expect(screen.queryByLabelText("Tickets de Implementer")).toBeNull();
await openAgentDetails("Implementer");
const list = await screen.findByLabelText("Implementer tickets");
expect(within(list).getByText("#1 In progress")).toBeTruthy();
expect(within(list).getByText("Human")).toBeTruthy();
const list = await screen.findByLabelText("Tickets de Implementer");
expect(within(list).getByText("#1 En cours")).toBeTruthy();
expect(within(list).getByText("Humain")).toBeTruthy();
expect(within(list).getByText("Wire the workstate queue UI")).toBeTruthy();
expect(within(list).getByLabelText("ticket ticket-i")).toBeTruthy();
expect(within(list).getByLabelText("9 more characters")).toBeTruthy();
expect(within(list).getByLabelText("9 caractères supplémentaires")).toBeTruthy();
});
it("keeps long ticket intent out of the default agent row", async () => {
@ -233,7 +233,7 @@ describe("ProjectWorkStatePanel", () => {
expect(screen.queryByText(longPreview)).toBeNull();
expect(screen.getByText("Goal: inspect the entire repository and p...")).toBeTruthy();
await openAgentDetails("Git");
const list = await screen.findByLabelText("Git tickets");
const list = await screen.findByLabelText("Tickets de Git");
expect(within(list).getByText(longPreview)).toBeTruthy();
});
@ -275,13 +275,13 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Reviewer")).toBeTruthy();
expect(screen.queryByLabelText("Reviewer tickets")).toBeNull();
expect(screen.queryByLabelText("Tickets de Reviewer")).toBeNull();
await openAgentDetails("Reviewer");
const list = await screen.findByLabelText("Reviewer tickets");
const list = await screen.findByLabelText("Tickets de Reviewer");
expect(within(list).getByText("QA (agent-qa)")).toBeTruthy();
expect(within(list).getByText("Main (agent-ma)")).toBeTruthy();
expect(list.textContent?.indexOf("#1 In progress")).toBeLessThan(
list.textContent?.indexOf("#2 Queued") ?? -1,
expect(list.textContent?.indexOf("#1 En cours")).toBeLessThan(
list.textContent?.indexOf("#2 En attente") ?? -1,
);
});
@ -381,42 +381,42 @@ describe("ProjectWorkStatePanel", () => {
expect(await screen.findByText("Runner")).toBeTruthy();
expect(screen.getByText("6 bg")).toBeTruthy();
expect(screen.queryByText("6 background tasks (3 active, 3 failed)")).toBeNull();
expect(screen.queryByLabelText("Runner background tasks")).toBeNull();
expect(screen.queryByText("6 tâches de fond (3 actives, 3 en échec)")).toBeNull();
expect(screen.queryByLabelText("Tâches de fond de Runner")).toBeNull();
const list = await openBackgroundTasks("Runner");
const cancelButtons = within(list).getAllByRole("button", { name: "Cancel" });
const retryButtons = within(list).getAllByRole("button", { name: "Retry" });
const cancelButtons = within(list).getAllByRole("button", { name: "Annuler" });
const retryButtons = within(list).getAllByRole("button", { name: "Réessayer" });
// waiting + queued + cancelling are cancellable; failed + expired + cancelFailed are retryable.
expect(cancelButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(3);
expect(retryButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(3);
expect(list.textContent).toContain("Cancelling");
expect(list.textContent).toContain("Cancel failed");
expect(list.textContent).toContain("Annulation…");
expect(list.textContent).toContain("Échec de l'annulation");
const rows = within(list).getAllByRole("listitem");
const rowTexts = rows.map((row) => row.textContent ?? "");
expect(rowTexts).toHaveLength(6);
const cancelFailedRow = rows[0] as HTMLElement;
const cancellingRow = rows[1] as HTMLElement;
expect(within(cancellingRow).getByText("Cancelling")).toBeTruthy();
expect(within(cancellingRow).getByText("Annulation…")).toBeTruthy();
expect(
(within(cancellingRow).getByRole("button", {
name: "Cancel",
name: "Annuler",
}) as HTMLButtonElement).disabled,
).toBe(false);
expect(within(cancelFailedRow).getByText("Cancel failed")).toBeTruthy();
expect(within(cancelFailedRow).getByText("Échec de l'annulation")).toBeTruthy();
expect(
(within(cancelFailedRow).getByRole("button", {
name: "Retry",
name: "Réessayer",
}) as HTMLButtonElement).disabled,
).toBe(false);
// Rows are ordered most-recently-updated first (updatedAtMs desc), taskId as
// the deterministic tiebreak — locks the fix for the dead finishedAtMs sort.
expect(rowTexts[0]).toContain("Cancel failed");
expect(rowTexts[1]).toContain("Cancelling");
expect(rowTexts[2]).toContain("Cancelled");
expect(rowTexts[3]).toContain("Failed");
expect(rowTexts[4]).toContain("Pending");
expect(rowTexts[5]).toContain("Pending");
expect(rowTexts[0]).toContain("Échec de l'annulation");
expect(rowTexts[1]).toContain("Annulation…");
expect(rowTexts[2]).toContain("Annulée");
expect(rowTexts[3]).toContain("Échec");
expect(rowTexts[4]).toContain("En attente");
expect(rowTexts[5]).toContain("En attente");
});
it("shows a terminal task summary and hides it when absent or non-terminal (#5)", async () => {
@ -470,20 +470,20 @@ describe("ProjectWorkStatePanel", () => {
expect(await screen.findByText("Reporter")).toBeTruthy();
expect(screen.getByText("4 bg")).toBeTruthy();
expect(screen.queryByText("4 background tasks (1 active, 1 failed)")).toBeNull();
expect(screen.queryByLabelText("Reporter background tasks")).toBeNull();
expect(screen.queryByText("4 tâches de fond (1 active, 1 en échec)")).toBeNull();
expect(screen.queryByLabelText("Tâches de fond de Reporter")).toBeNull();
const list = await openBackgroundTasks("Reporter");
// Terminal tasks with a summary render it discreetly.
expect(
within(list).getByLabelText("task done-wit summary").textContent,
within(list).getByLabelText("résumé de la tâche done-wit").textContent,
).toBe("Build succeeded in 12s");
expect(
within(list).getByText("Compile error: missing semicolon"),
).toBeTruthy();
// A completed task without a summary shows no summary line.
expect(within(list).queryByLabelText("task done-no- summary")).toBeNull();
expect(within(list).queryByLabelText("résumé de la tâche done-no-")).toBeNull();
// A still-running task never shows its (non-terminal) summary.
expect(within(list).queryByLabelText("task run-with summary")).toBeNull();
expect(within(list).queryByLabelText("résumé de la tâche run-with")).toBeNull();
expect(
within(list).queryByText("should-not-appear-while-running"),
).toBeNull();
@ -519,29 +519,29 @@ describe("ProjectWorkStatePanel", () => {
const view = renderPanel(workState);
const list = await openBackgroundTasks("Runner");
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
fireEvent.click(within(list).getByRole("button", { name: "Direct" }));
expect(attachSpy).toHaveBeenCalledWith("task-live-output", expect.any(Function));
expect(
(await within(list).findByLabelText("task task-liv live output")).textContent,
(await within(list).findByLabelText("sortie directe de la tâche task-liv")).textContent,
).toContain("already printed");
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1);
workState._emitBackgroundTaskOutput("task-live-output", "new line\n");
await waitFor(() =>
expect(
within(list).getByLabelText("task task-liv live output").textContent,
within(list).getByLabelText("sortie directe de la tâche task-liv").textContent,
).toContain("new line"),
);
fireEvent.click(within(list).getByRole("button", { name: "Detach" }));
fireEvent.click(within(list).getByRole("button", { name: "Détacher" }));
await waitFor(() =>
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0),
);
expect(within(list).queryByLabelText("task task-liv live output")).toBeNull();
expect(within(list).queryByLabelText("sortie directe de la tâche task-liv")).toBeNull();
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
await within(list).findByLabelText("task task-liv live output");
fireEvent.click(within(list).getByRole("button", { name: "Direct" }));
await within(list).findByLabelText("sortie directe de la tâche task-liv");
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1);
view.unmount();
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0);
@ -563,9 +563,9 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Legacy")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.queryByText("Idle")).toBeNull();
expect(screen.queryByLabelText("Legacy tickets")).toBeNull();
expect(screen.getByText("Hors ligne")).toBeTruthy();
expect(screen.queryByText("Inactif")).toBeNull();
expect(screen.queryByLabelText("Tickets de Legacy")).toBeNull();
});
it("ignores a stray top-level backgroundTasks array (nested tasks intact) (#5)", async () => {
@ -631,10 +631,10 @@ describe("ProjectWorkStatePanel", () => {
);
expect(screen.getByRole("alert").textContent).toContain(
"Work panel failed to render.",
"Le panneau Travail n'a pas pu s'afficher.",
);
expect(screen.getByText("render exploded")).toBeTruthy();
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
});
it("renders conversation objective and summary joined by conversation id", async () => {
@ -695,18 +695,18 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Continue implementation")).toBeTruthy();
expect(screen.queryByLabelText("Summarizer tickets")).toBeNull();
expect(screen.queryByText("Goal: Ship the Work panel summaries")).toBeNull();
expect(screen.queryByLabelText("Tickets de Summarizer")).toBeNull();
expect(screen.queryByText("Objectif : Ship the Work panel summaries")).toBeNull();
expect(
screen.queryByText("Previous turn established the adapter boundary."),
).toBeNull();
expect(screen.queryByRole("button", { name: "View" })).toBeNull();
expect(screen.queryByRole("button", { name: "Copy" })).toBeNull();
expect(screen.queryByRole("button", { name: "Afficher" })).toBeNull();
expect(screen.queryByRole("button", { name: "Copier" })).toBeNull();
await openAgentDetails("Summarizer");
const list = await screen.findByLabelText("Summarizer tickets");
expect(within(list).getAllByText("Summary")).toHaveLength(2);
const list = await screen.findByLabelText("Tickets de Summarizer");
expect(within(list).getAllByText("Résumé")).toHaveLength(2);
expect(
within(list).getByText("Goal: Ship the Work panel summaries"),
within(list).getByText("Objectif : Ship the Work panel summaries"),
).toBeTruthy();
expect(
within(list).getByText(
@ -781,18 +781,18 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Validate partial context")).toBeTruthy();
expect(screen.queryByLabelText("Contextualizer tickets")).toBeNull();
expect(screen.queryByLabelText("Tickets de Contextualizer")).toBeNull();
await openAgentDetails("Contextualizer");
const list = await screen.findByLabelText("Contextualizer tickets");
expect(within(list).getByText("#1 In progress")).toBeTruthy();
const list = await screen.findByLabelText("Tickets de Contextualizer");
expect(within(list).getByText("#1 En cours")).toBeTruthy();
expect(within(list).getByText("Validate partial context")).toBeTruthy();
expect(within(list).getByText("Partial")).toBeTruthy();
expect(within(list).getByText("Partiel")).toBeTruthy();
expect(
within(list).getByText("Last: Only the latest prompt was available."),
within(list).getByText("Dernier : Only the latest prompt was available."),
).toBeTruthy();
expect(within(list).getByText("#2 Queued")).toBeTruthy();
expect(within(list).getByText("#2 En attente")).toBeTruthy();
expect(within(list).getByText("Validate unavailable context")).toBeTruthy();
expect(within(list).getByText("Unavailable")).toBeTruthy();
expect(within(list).getByText("Indisponible")).toBeTruthy();
expect(
within(list).getByText("Summary service unavailable right now."),
).toBeTruthy();
@ -803,7 +803,7 @@ describe("ProjectWorkStatePanel", () => {
const system = new MockSystemGateway();
renderPanel(workState, system);
await screen.findByText("No agent work state.");
await screen.findByText("Aucun état de travail d'agent.");
const spy = vi.spyOn(workState, "getProjectWorkState");
workState._setProjectWorkState(PROJECT_ID, {
agents: [
@ -831,7 +831,7 @@ describe("ProjectWorkStatePanel", () => {
const system = new MockSystemGateway();
renderPanel(workState, system);
await screen.findByText("No agent work state.");
await screen.findByText("Aucun état de travail d'agent.");
const spy = vi.spyOn(workState, "getProjectWorkState");
workState._setProjectWorkState(PROJECT_ID, {
agents: [
@ -865,7 +865,7 @@ describe("ProjectWorkStatePanel", () => {
expect(await screen.findByText("Delegatee")).toBeTruthy();
await openAgentDetails("Delegatee");
expect(screen.getByText("#1 In progress")).toBeTruthy();
expect(screen.getByText("#1 En cours")).toBeTruthy();
await waitFor(() => expect(spy).toHaveBeenCalled());
});
@ -895,7 +895,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState, new MockSystemGateway(), { layout, agent });
await openAgentDetails("Visible");
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
fireEvent.click(await screen.findByRole("button", { name: "Ouvrir" }));
expect(host.style.outline).toContain("2px solid");
expect(launchSpy).not.toHaveBeenCalled();
@ -936,7 +936,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState, new MockSystemGateway(), { layout, agent });
await openAgentDetails("Hidden");
const attach = await screen.findByRole("button", { name: "Attach" });
const attach = await screen.findByRole("button", { name: "Attacher" });
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(attach);
@ -985,9 +985,9 @@ describe("ProjectWorkStatePanel", () => {
});
await openAgentDetails("Hidden");
const attach = await screen.findByRole("button", { name: "Attach" });
const attach = await screen.findByRole("button", { name: "Attacher" });
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(true));
expect(await screen.findByText("No empty visible cell.")).toBeTruthy();
expect(await screen.findByText("Aucune cellule visible vide.")).toBeTruthy();
});
it("Stop confirms with a busy warning, stops the live agent, and refreshes Work", async () => {
@ -1050,11 +1050,11 @@ describe("ProjectWorkStatePanel", () => {
await openAgentDetails("Runner");
const row = await openAgentDetails("Runner");
fireEvent.click(within(row).getByRole("button", { name: "Stop" }));
fireEvent.click(within(row).getByRole("button", { name: "Arrêter" }));
await waitFor(() =>
expect(confirmSpy).toHaveBeenCalledWith(
"Stop Runner? The current turn will be interrupted.",
"Arrêter Runner ? Le tour en cours sera interrompu.",
),
);
await waitFor(() =>
@ -1067,7 +1067,7 @@ describe("ProjectWorkStatePanel", () => {
const stopping = within(row).getByRole("button", { name: "Arrêt en cours…" });
expect((stopping as HTMLButtonElement).disabled).toBe(true);
expect(within(row).getAllByText("Arrêt en cours…")).toHaveLength(2);
expect(within(row).queryByText("Busy")).toBeNull();
expect(within(row).queryByText("Occupé")).toBeNull();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
@ -1096,7 +1096,7 @@ describe("ProjectWorkStatePanel", () => {
});
await waitFor(() => expect(refreshSpy).toHaveBeenCalled());
await waitFor(() => expect(within(row).getAllByText("Arrêté")).toHaveLength(1));
expect(within(row).queryByText("Busy")).toBeNull();
expect(within(row).queryByText("Occupé")).toBeNull();
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];
expect(leaf.session).toBeNull();
@ -1130,7 +1130,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState, new MockSystemGateway(), { agent });
const row = await openAgentDetails("Main");
fireEvent.click(within(row).getByRole("button", { name: "Stop" }));
fireEvent.click(within(row).getByRole("button", { name: "Arrêter" }));
expect(await within(row).findByText("Arrêt incomplet")).toBeTruthy();
expect(within(row).getByRole("alert").textContent).toBe(
@ -1197,12 +1197,15 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
await openAgentDetails("Viewer");
const list = await screen.findByLabelText("Viewer tickets");
fireEvent.click(within(list).getByRole("button", { name: "View" }));
const list = await screen.findByLabelText("Tickets de Viewer");
fireEvent.click(within(list).getByRole("button", { name: "Afficher" }));
const details = within(list).getByLabelText(
"détails de la conversation conversation-view",
);
expect(within(list).getByText("Goal:")).toBeTruthy();
expect(within(list).getByText("Keep it compact")).toBeTruthy();
expect(within(list).getByText("Summary:")).toBeTruthy();
expect(within(details).getByText(/^Objectif/)).toBeTruthy();
expect(within(details).getByText("Keep it compact")).toBeTruthy();
expect(within(details).getByText(/^Résumé/)).toBeTruthy();
expect(
within(list).getByText("Only the exposed summary preview."),
).toBeTruthy();
@ -1260,13 +1263,13 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
await openAgentDetails("Copier");
const list = await screen.findByLabelText("Copier tickets");
fireEvent.click(within(list).getByRole("button", { name: "Copy" }));
const list = await screen.findByLabelText("Tickets de Copier");
fireEvent.click(within(list).getByRole("button", { name: "Copier" }));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith("Copy this summary preview only."),
);
expect(await within(list).findByRole("button", { name: "Copied" })).toBeTruthy();
expect(await within(list).findByRole("button", { name: "Copié" })).toBeTruthy();
vi.unstubAllGlobals();
});
});