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

@ -55,7 +55,7 @@ async function waitForMemoryIdle() {
await waitFor(() => {
expect(
(
screen.getByRole("button", { name: "create memory" }) as HTMLButtonElement
screen.getByRole("button", { name: "créer une mémoire" }) as HTMLButtonElement
).disabled,
).toBe(false);
});
@ -69,21 +69,21 @@ async function createMemory(
type: "user" | "feedback" | "project" | "reference" = "project",
) {
await waitForMemoryIdle();
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
await waitFor(() => expect(screen.getByLabelText("memory name")).toBeTruthy());
fireEvent.change(screen.getByLabelText("memory name"), {
fireEvent.click(screen.getByRole("button", { name: "créer une mémoire" }));
await waitFor(() => expect(screen.getByLabelText("nom de la mémoire")).toBeTruthy());
fireEvent.change(screen.getByLabelText("nom de la mémoire"), {
target: { value: name },
});
fireEvent.change(screen.getByLabelText("memory description"), {
fireEvent.change(screen.getByLabelText("description de la mémoire"), {
target: { value: description },
});
fireEvent.change(screen.getByLabelText("memory type"), {
fireEvent.change(screen.getByLabelText("type de mémoire"), {
target: { value: type },
});
fireEvent.change(screen.getByLabelText("memory content"), {
fireEvent.change(screen.getByLabelText("contenu de la mémoire"), {
target: { value: content },
});
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer la note" }));
}
// ---------------------------------------------------------------------------
@ -94,7 +94,7 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
it("shows the empty state initially", async () => {
renderMemoryPanel();
await waitForMemoryIdle();
expect(screen.getByText("No memory notes yet.")).toBeTruthy();
expect(screen.getByText("Aucune note de mémoire.")).toBeTruthy();
});
it("renders the index (title + hook + type) on mount", async () => {
@ -141,27 +141,27 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
it("Save is disabled until description and content are filled", async () => {
renderMemoryPanel();
await waitForMemoryIdle();
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
fireEvent.click(screen.getByRole("button", { name: "créer une mémoire" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Save note" })).toBeTruthy(),
expect(screen.getByRole("button", { name: "Enregistrer la note" })).toBeTruthy(),
);
const save = screen.getByRole("button", {
name: "Save note",
name: "Enregistrer la note",
}) as HTMLButtonElement;
expect(save.disabled).toBe(true);
// Filling everything enables Save.
fireEvent.change(screen.getByLabelText("memory name"), {
fireEvent.change(screen.getByLabelText("nom de la mémoire"), {
target: { value: "X" },
});
fireEvent.change(screen.getByLabelText("memory description"), {
fireEvent.change(screen.getByLabelText("description de la mémoire"), {
target: { value: "h" },
});
fireEvent.change(screen.getByLabelText("memory content"), {
fireEvent.change(screen.getByLabelText("contenu de la mémoire"), {
target: { value: "c" },
});
expect(
(screen.getByRole("button", { name: "Save note" }) as HTMLButtonElement)
(screen.getByRole("button", { name: "Enregistrer la note" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
@ -172,24 +172,24 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
await screen.findByText("Editable Note");
fireEvent.click(
screen.getByRole("button", { name: "edit memory Editable Note" }),
screen.getByRole("button", { name: "modifier la mémoire Editable Note" }),
);
// Slug shown read-only — there's no editable "memory name" input in edit-mode.
await waitFor(() => expect(screen.getByText("editable-note")).toBeTruthy());
expect(screen.queryByLabelText("memory name")).toBeNull();
expect(screen.queryByLabelText("nom de la mémoire")).toBeNull();
// Description / type / content are editable.
fireEvent.change(screen.getByLabelText("memory description"), {
fireEvent.change(screen.getByLabelText("description de la mémoire"), {
target: { value: "new hook" },
});
fireEvent.change(screen.getByLabelText("memory type"), {
fireEvent.change(screen.getByLabelText("type de mémoire"), {
target: { value: "feedback" },
});
fireEvent.change(screen.getByLabelText("memory content"), {
fireEvent.change(screen.getByLabelText("contenu de la mémoire"), {
target: { value: "new body" },
});
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer la note" }));
await waitFor(async () => {
const note = await memory.getMemory(PROJECT_ID, "editable-note");
@ -205,7 +205,7 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
await screen.findByText("To Delete");
fireEvent.click(
screen.getByRole("button", { name: "delete memory To Delete" }),
screen.getByRole("button", { name: "supprimer la mémoire To Delete" }),
);
await waitFor(() => expect(screen.queryByText("To Delete")).toBeNull());
});
@ -231,7 +231,7 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
renderMemoryPanel(memory);
fireEvent.click(
await screen.findByRole("button", { name: "edit memory Source Note" }),
await screen.findByRole("button", { name: "modifier la mémoire Source Note" }),
);
// The resolved link to the existing note appears; the broken one does not.