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

@ -124,7 +124,7 @@ describe("AgentLimitBadge (render)", () => {
const expected = `limité jusqu'à ${formatResetTime(at)}`;
expect(screen.getByText(expected)).toBeTruthy();
// No countdown / cancel button without an armed resume.
expect(screen.queryByLabelText("cancel resume")).toBeNull();
expect(screen.queryByLabelText("annuler la reprise")).toBeNull();
});
it("shows plain 'limité' when no reset time is known", () => {
@ -145,8 +145,8 @@ describe("AgentLimitBadge (render)", () => {
expect(screen.getByText("limité")).toBeTruthy();
expect(screen.getByText(/heure inconnue/)).toBeTruthy();
// The human-net form: a resume-time input + a "schedule resume" button.
expect(screen.getByLabelText("resume time")).toBeTruthy();
expect(screen.getByLabelText("schedule resume")).toBeTruthy();
expect(screen.getByLabelText("heure de reprise")).toBeTruthy();
expect(screen.getByLabelText("programmer la reprise")).toBeTruthy();
});
it("does NOT show the resume-time form when a suspected limit has a time", () => {
@ -160,7 +160,7 @@ describe("AgentLimitBadge (render)", () => {
);
expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy();
expect(screen.queryByText(/heure inconnue/)).toBeNull();
expect(screen.queryByLabelText("resume time")).toBeNull();
expect(screen.queryByLabelText("heure de reprise")).toBeNull();
});
it("submitting the resume-time form arms a resume at the chosen epoch-ms", () => {
@ -172,10 +172,10 @@ describe("AgentLimitBadge (render)", () => {
onSetResumeAt={onSetResumeAt}
/>,
);
fireEvent.change(screen.getByLabelText("resume time"), {
fireEvent.change(screen.getByLabelText("heure de reprise"), {
target: { value: "23:45" },
});
fireEvent.click(screen.getByLabelText("schedule resume"));
fireEvent.click(screen.getByLabelText("programmer la reprise"));
expect(onSetResumeAt).toHaveBeenCalledTimes(1);
const ms = onSetResumeAt.mock.calls[0][0] as number;
const d = new Date(ms);
@ -193,7 +193,7 @@ describe("AgentLimitBadge (render)", () => {
/>,
);
expect(
(screen.getByLabelText("schedule resume") as HTMLButtonElement).disabled,
(screen.getByLabelText("programmer la reprise") as HTMLButtonElement).disabled,
).toBe(true);
expect(onSetResumeAt).not.toHaveBeenCalled();
});
@ -209,11 +209,11 @@ describe("AgentLimitBadge (render)", () => {
/>,
);
// Countdown label present (≈ "reprise dans 1m 30s").
expect(screen.getByLabelText("resume countdown").textContent).toMatch(
expect(screen.getByLabelText("compte à rebours de reprise").textContent).toMatch(
/reprise dans/,
);
const btn = screen.getByLabelText("cancel resume");
const btn = screen.getByLabelText("annuler la reprise");
fireEvent.click(btn);
expect(onCancelResume).toHaveBeenCalledTimes(1);
});
@ -228,7 +228,7 @@ describe("AgentLimitBadge (render)", () => {
/>,
);
expect(
(screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled,
(screen.getByLabelText("annuler la reprise") as HTMLButtonElement).disabled,
).toBe(true);
});
});