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

@ -307,7 +307,7 @@ describe("TicketsView", () => {
it("shows the empty state then a created ticket", async () => {
renderView(ticket, system, agent);
expect(await screen.findByText("No tickets.")).toBeTruthy();
expect(await screen.findByText("Aucun ticket.")).toBeTruthy();
await ticket.create(PROJECT_ID, { title: "Live created" });
// The panel refreshes on the issueCreated event.
@ -319,21 +319,21 @@ describe("TicketsView", () => {
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "New" }));
fireEvent.change(screen.getByLabelText("new ticket title"), {
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "With sprint" },
});
// Field defaults to "Sans sprint"; open the picker and pick Alpha.
const sprintField = screen.getByLabelText("choose sprint for new ticket");
const sprintField = screen.getByLabelText("Choisir un sprint pour le nouveau ticket");
expect(sprintField.textContent).toContain("Sans sprint");
fireEvent.click(sprintField);
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Alpha"));
expect(
screen.getByLabelText("choose sprint for new ticket").textContent,
screen.getByLabelText("Choisir un sprint pour le nouveau ticket").textContent,
).toContain("Alpha");
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
// create → setTicketSprint(projectId, ref, "s1", version).
await waitFor(() => expect(setSpy).toHaveBeenCalledTimes(1));
@ -350,11 +350,11 @@ describe("TicketsView", () => {
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "New" }));
fireEvent.change(screen.getByLabelText("new ticket title"), {
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "No sprint" },
});
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
await waitFor(async () => {
const list = await ticket.list(PROJECT_ID);
@ -371,13 +371,13 @@ describe("TicketsView", () => {
});
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "New" }));
fireEvent.change(screen.getByLabelText("new ticket title"), {
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "Kept anyway" },
});
fireEvent.click(screen.getByLabelText("choose sprint for new ticket"));
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByLabelText("Choisir un sprint pour le nouveau ticket"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Alpha"));
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
// The ticket was created despite the failed assignment (no data lost)…
await waitFor(async () => {
@ -415,14 +415,14 @@ describe("TicketsView", () => {
// Tick two statuses → OR within the facet ⇒ open|closed ⇒ Alpha + Bravo,
// Charlie (QA) drops out.
fireEvent.click(screen.getByLabelText("filter status Open"));
fireEvent.click(screen.getByLabelText("filter status Closed"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Ouvert"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Fermé"));
await waitFor(() => expect(screen.queryByText("Charlie")).toBeNull());
expect(screen.getByText("Alpha")).toBeTruthy();
expect(screen.getByText("Bravo")).toBeTruthy();
// Add a priority → AND across facets ⇒ (open|closed) AND high ⇒ only Alpha.
fireEvent.click(screen.getByLabelText("filter priority High"));
fireEvent.click(screen.getByLabelText("Filtrer la priorité Haute"));
await waitFor(() => expect(screen.queryByText("Bravo")).toBeNull());
expect(screen.getByText("Alpha")).toBeTruthy();
@ -435,7 +435,7 @@ describe("TicketsView", () => {
);
// « Effacer » empties both facets ⇒ all three back.
fireEvent.click(screen.getByLabelText("clear filters"));
fireEvent.click(screen.getByLabelText("Effacer les filtres"));
await waitFor(() => expect(screen.getByText("Charlie")).toBeTruthy());
expect(screen.getByText("Bravo")).toBeTruthy();
expect(screen.getByText("Alpha")).toBeTruthy();
@ -453,7 +453,7 @@ describe("TicketsView", () => {
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined();
// Choose a field ⇒ ascending by default, relayed in the query.
fireEvent.click(screen.getByLabelText("sort tickets by"));
fireEvent.click(screen.getByLabelText("Trier les tickets par"));
fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
@ -464,7 +464,7 @@ describe("TicketsView", () => {
// The direction toggle flips asc → desc.
fireEvent.click(
screen.getByLabelText("sort direction ascending"),
screen.getByLabelText("Sens de tri croissant"),
);
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
@ -474,7 +474,7 @@ describe("TicketsView", () => {
);
// Back to « Par défaut » ⇒ `sort` dropped from the query again.
fireEvent.click(screen.getByLabelText("sort tickets by"));
fireEvent.click(screen.getByLabelText("Trier les tickets par"));
fireEvent.click(await screen.findByRole("option", { name: "Par défaut" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(),
@ -496,10 +496,10 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
await screen.findByText("Created by user");
expect(screen.getByText("Créé par : Utilisateur")).toBeTruthy();
expect(screen.getByText("Créé par : Agent : CreatorAgent")).toBeTruthy();
expect(screen.getByText("Créé par: Utilisateur")).toBeTruthy();
expect(screen.getByText("Créé par: Agent : CreatorAgent")).toBeTruthy();
fireEvent.click(screen.getByLabelText("filter by creator"));
fireEvent.click(screen.getByLabelText("Filtrer par créateur"));
fireEvent.click(await screen.findByRole("option", { name: "CreatorAgent" }));
await waitFor(() =>
@ -513,10 +513,10 @@ describe("TicketsView", () => {
fireEvent.click(screen.getByText("Created by agent"));
const dialog = await screen.findByRole("dialog", {
name: `ticket ${agentTicket.ref}`,
name: `Ticket ${agentTicket.ref}`,
});
expect(
within(dialog).getByText("Créé par : Agent : CreatorAgent"),
within(dialog).getByText("Créé par: Agent : CreatorAgent"),
).toBeTruthy();
});
@ -527,8 +527,8 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Editable"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByLabelText("ticket status"));
fireEvent.click(await screen.findByRole("option", { name: "In progress" }));
fireEvent.click(within(dialog).getByLabelText("Statut du ticket"));
fireEvent.click(await screen.findByRole("option", { name: "En cours" }));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
@ -544,10 +544,10 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("WithCarnet"));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("ticket carnet"), {
fireEvent.change(within(dialog).getByLabelText("Carnet du ticket"), {
target: { value: "## notes\nscoped body" },
});
fireEvent.click(within(dialog).getByText("Save carnet"));
fireEvent.click(within(dialog).getByText("Enregistrer le carnet"));
await waitFor(async () => {
const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
@ -560,7 +560,7 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Attachable"));
const dialog = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
const dialog = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
expect(within(dialog).getByText("Pièces jointes")).toBeTruthy();
expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy();
@ -569,16 +569,16 @@ describe("TicketsView", () => {
expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy();
expect(within(dialog).getByText(/text\/plain/)).toBeTruthy();
expect(within(dialog).getByText("Résumé : non")).toBeTruthy();
expect(within(dialog).getByText("Résumé: non")).toBeTruthy();
const attached = await ticket.read(PROJECT_ID, t.ref);
expect(attached.attachments).toHaveLength(1);
fireEvent.click(
within(dialog).getByLabelText("marquer résumé mock-attachment.txt"),
within(dialog).getByLabelText("Marquer mock-attachment.txt comme résumé"),
);
expect(await within(dialog).findByText("Résumé : oui")).toBeTruthy();
expect(await within(dialog).findByText("Résumé: oui")).toBeTruthy();
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.attachments[0].summarizedInCarnet).toBe(true);
@ -593,9 +593,9 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Assignable"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByLabelText("assign agent"));
fireEvent.click(within(dialog).getByLabelText("Assigner un agent"));
fireEvent.click(await screen.findByRole("option", { name: "Backend" }));
fireEvent.click(within(dialog).getByText("Assign"));
fireEvent.click(within(dialog).getByText("Assigner"));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
@ -627,19 +627,19 @@ describe("TicketsView", () => {
expectedVersion: t.version,
});
fireEvent.change(within(dialog).getByLabelText("Title"), {
fireEvent.change(within(dialog).getByLabelText("Titre"), {
target: { value: "Racy edited" },
});
fireEvent.click(within(dialog).getByText("Save changes"));
fireEvent.click(within(dialog).getByText("Enregistrer les modifications"));
expect(
await within(dialog).findByText(/modified elsewhere and reloaded/i),
await within(dialog).findByText(/modifié ailleurs et rechargé/i),
).toBeTruthy();
// After the reload the detail field reflects the reloaded (title unchanged)
// ticket rather than the abandoned local edit.
await waitFor(() =>
expect(
(within(dialog).getByLabelText("Title") as HTMLInputElement)
(within(dialog).getByLabelText("Titre") as HTMLInputElement)
.value,
).toBe("Racy"),
);
@ -656,13 +656,13 @@ describe("TicketsView", () => {
const desc = within(dialog).getByLabelText("Description") as HTMLTextAreaElement;
fireEvent.change(desc, { target: { value: "draft in progress" } });
// Also edit the title to cover both draft fields.
const titleInput = within(dialog).getByLabelText("Title") as HTMLInputElement;
const titleInput = within(dialog).getByLabelText("Titre") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Persisted (wip)" } });
// Change the priority — an immediate-apply mutation that re-fetches the ticket
// and bumps its version. The unsaved draft must survive.
fireEvent.click(within(dialog).getByLabelText("ticket priority"));
fireEvent.click(await screen.findByRole("option", { name: "High" }));
fireEvent.click(within(dialog).getByLabelText("Priorité du ticket"));
fireEvent.click(await screen.findByRole("option", { name: "Haute" }));
// The backend applied the priority bump…
await waitFor(async () => {
@ -676,7 +676,7 @@ describe("TicketsView", () => {
(within(dialog).getByLabelText("Description") as HTMLTextAreaElement).value,
).toBe("draft in progress");
expect(
(within(dialog).getByLabelText("Title") as HTMLInputElement).value,
(within(dialog).getByLabelText("Titre") as HTMLInputElement).value,
).toBe("Persisted (wip)");
// The description was never persisted.
const persisted = await ticket.read(PROJECT_ID, t.ref);
@ -698,34 +698,34 @@ describe("TicketsView", () => {
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
const detail = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
// Clean state → Close fires onClose directly, no confirmation dialog.
fireEvent.click(within(detail).getByText("Close"));
fireEvent.click(within(detail).getByText("Fermer"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull();
expect(screen.queryByRole("dialog", { name: "Modifications non enregistrées" })).toBeNull();
// Now make the draft dirty and try to close again.
fireEvent.change(within(detail).getByLabelText("Description"), {
target: { value: "unsaved body" },
});
fireEvent.click(within(detail).getByText("Close"));
fireEvent.click(within(detail).getByText("Fermer"));
// The confirmation appears and onClose was NOT called again.
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
const confirm = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
expect(onClose).toHaveBeenCalledTimes(1);
// "Annuler" dismisses the popup and keeps the editor open.
fireEvent.click(within(confirm).getByLabelText("cancel close"));
fireEvent.click(within(confirm).getByLabelText("Annuler la fermeture"));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull(),
expect(screen.queryByRole("dialog", { name: "Modifications non enregistrées" })).toBeNull(),
);
expect(onClose).toHaveBeenCalledTimes(1);
// Re-open the confirmation and choose "Fermer sans sauvegarder".
fireEvent.click(within(detail).getByText("Close"));
const confirm2 = await screen.findByRole("dialog", { name: "unsaved changes" });
fireEvent.click(within(confirm2).getByLabelText("close without saving"));
fireEvent.click(within(detail).getByText("Fermer"));
const confirm2 = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
fireEvent.click(within(confirm2).getByLabelText("Fermer sans enregistrer"));
expect(onClose).toHaveBeenCalledTimes(2);
// The unsaved description was discarded (never persisted).
const fresh = await ticket.read(PROJECT_ID, t.ref);
@ -747,14 +747,14 @@ describe("TicketsView", () => {
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
const detail = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
fireEvent.change(within(detail).getByLabelText("Description"), {
target: { value: "final body" },
});
fireEvent.click(within(detail).getByText("Close"));
fireEvent.click(within(detail).getByText("Fermer"));
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
fireEvent.click(within(confirm).getByLabelText("save and close"));
const confirm = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
fireEvent.click(within(confirm).getByLabelText("Enregistrer et fermer"));
// The change is persisted and the panel closes.
await waitFor(async () => {
@ -777,21 +777,21 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
// Sections appear in sprint `order`, then the "Sans sprint" bucket last.
await screen.findByRole("region", { name: "sprint section Sprint One" });
await screen.findByRole("region", { name: "Section du sprint Sprint One" });
const sections = screen
.getAllByRole("region")
.map((r) => r.getAttribute("aria-label"))
.filter((l) => l?.startsWith("sprint section"));
.filter((l) => l?.startsWith("Section du sprint"));
expect(sections).toEqual([
"sprint section Sprint One",
"sprint section Sprint Two",
"sprint section Sans sprint",
"Section du sprint Sprint One",
"Section du sprint Sprint Two",
"Section du sprint Sans sprint",
]);
// The loose ticket lives in the bucket, the others under their sprint.
const bucket = screen.getByRole("region", { name: "sprint section Sans sprint" });
const bucket = screen.getByRole("region", { name: "Section du sprint Sans sprint" });
expect(within(bucket).getByText("Loose")).toBeTruthy();
const one = screen.getByRole("region", { name: "sprint section Sprint One" });
const one = screen.getByRole("region", { name: "Section du sprint Sprint One" });
expect(within(one).getByText("In one")).toBeTruthy();
});
@ -803,12 +803,12 @@ describe("TicketsView", () => {
// It starts in the "Sans sprint" bucket.
const bucket = await screen.findByRole("region", {
name: "sprint section Sans sprint",
name: "Section du sprint Sans sprint",
});
expect(within(bucket).getByText("Movable")).toBeTruthy();
// Pick the sprint in the row selector → assign it.
fireEvent.click(screen.getByLabelText(`sprint for ${t.ref}`));
fireEvent.click(screen.getByLabelText(`Sprint de ${t.ref}`));
fireEvent.click(await screen.findByRole("option", { name: "Sprint One" }));
// The gateway recorded the membership…
@ -819,12 +819,12 @@ describe("TicketsView", () => {
// …and the UI regrouped it under Sprint One, emptying the bucket.
await waitFor(() => {
const one = screen.getByRole("region", {
name: "sprint section Sprint One",
name: "Section du sprint Sprint One",
});
expect(within(one).getByText("Movable")).toBeTruthy();
});
expect(
screen.queryByRole("region", { name: "sprint section Sans sprint" }),
screen.queryByRole("region", { name: "Section du sprint Sans sprint" }),
).toBeNull();
});
@ -834,15 +834,15 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Deletable"));
const detail = await screen.findByRole("dialog", {
name: `ticket ${t.ref}`,
name: `Ticket ${t.ref}`,
});
// Open the confirmation, then confirm the deletion.
fireEvent.click(within(detail).getByText("Supprimer"));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression",
name: "Confirmer la suppression",
});
fireEvent.click(within(confirm).getByLabelText("confirm delete"));
fireEvent.click(within(confirm).getByLabelText("Confirmer la suppression"));
// The gateway removed the ticket…
await waitFor(() =>
@ -853,12 +853,12 @@ describe("TicketsView", () => {
// …the detail closed (via the `issueDeleted` event → `deleted` flag)…
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: `ticket ${t.ref}` }),
screen.queryByRole("dialog", { name: `Ticket ${t.ref}` }),
).toBeNull(),
);
// …and the row disappeared from the list (event-driven refresh).
await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull());
expect(await screen.findByText("No tickets.")).toBeTruthy();
expect(await screen.findByText("Aucun ticket.")).toBeTruthy();
});
it("selects several rows and changes their status from the bulk action bar", async () => {
@ -869,10 +869,10 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
expect(screen.getByRole("toolbar", { name: "bulk ticket actions" })).toBeTruthy();
expect(screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeTruthy();
fireEvent.click(screen.getByLabelText("Changer le statut"));
fireEvent.click(await screen.findByRole("option", { name: "QA" }));
@ -883,7 +883,7 @@ describe("TicketsView", () => {
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
});
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
expect(screen.queryByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeNull();
});
it("selects several rows and assigns their sprint from the bulk action bar (#112)", async () => {
@ -895,12 +895,12 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
const toolbar = screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("select sprint Target"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Target"));
await waitFor(() => {
expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, a.ref, "s1", a.version);
@ -913,7 +913,7 @@ describe("TicketsView", () => {
expect(screen.getByRole("status").textContent).toMatch(
/Sprint mis à jour pour 2 tickets/i,
);
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
expect(screen.queryByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeNull();
});
it("keeps failed refs selected after a partial bulk sprint assignment (#112)", async () => {
@ -932,11 +932,11 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("select sprint Target"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Target"));
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
@ -947,11 +947,11 @@ describe("TicketsView", () => {
);
expect(screen.getByRole("status").textContent).toMatch(/1\/2 tickets/i);
expect(
(screen.getByLabelText(`select ticket row ${b.ref}`) as HTMLInputElement)
(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`) as HTMLInputElement)
.checked,
).toBe(true);
expect(
(screen.getByLabelText(`select ticket row ${a.ref}`) as HTMLInputElement)
(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`) as HTMLInputElement)
.checked,
).toBe(false);
});
@ -964,14 +964,14 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression groupée",
name: "Confirmer la suppression groupée",
});
fireEvent.click(within(confirm).getByLabelText("confirm bulk delete"));
fireEvent.click(within(confirm).getByLabelText("Confirmer la suppression groupée"));
await waitFor(() =>
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref]),
@ -988,24 +988,24 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Keeper"));
const detail = await screen.findByRole("dialog", {
name: `ticket ${t.ref}`,
name: `Ticket ${t.ref}`,
});
fireEvent.click(within(detail).getByText("Supprimer"));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression",
name: "Confirmer la suppression",
});
fireEvent.click(within(confirm).getByLabelText("cancel delete"));
fireEvent.click(within(confirm).getByLabelText("Annuler la suppression"));
// The confirmation is dismissed, delete was never called, ticket stays.
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: "confirmer la suppression" }),
screen.queryByRole("dialog", { name: "Confirmer la suppression" }),
).toBeNull(),
);
expect(delSpy).not.toHaveBeenCalled();
expect(
screen.getByRole("dialog", { name: `ticket ${t.ref}` }),
screen.getByRole("dialog", { name: `Ticket ${t.ref}` }),
).toBeTruthy();
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.title).toBe("Keeper");
@ -1018,7 +1018,7 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Delegate me"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Delegation context"));
fireEvent.click(within(dialog).getByText("Contexte de délégation"));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith(
@ -1044,12 +1044,12 @@ describe("TicketsView", () => {
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
const detail = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
// The assistant is opened from the prominent bottom-right floating button;
// it is not inline anymore (#17).
expect(within(detail).queryByLabelText("profil de l'assistant")).toBeNull();
fireEvent.click(within(detail).getByLabelText("ouvrir l'assistant IA"));
fireEvent.click(within(detail).getByLabelText("Ouvrir l’assistant IA"));
// It opens in its own nested window.
const assistant = await screen.findByRole("dialog", {
@ -1117,18 +1117,18 @@ describe("TicketsView", () => {
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${a.ref}` });
const detail = await screen.findByRole("dialog", { name: `Ticket ${a.ref}` });
// The old manual "#id" input is gone.
expect(within(detail).queryByLabelText("link target ref")).toBeNull();
// Choose the link kind, then open the picker and select the target ticket.
fireEvent.click(within(detail).getByLabelText("link kind"));
fireEvent.click(await screen.findByRole("option", { name: "blocks" }));
fireEvent.click(within(detail).getByLabelText("add link"));
fireEvent.click(within(detail).getByLabelText("Type de lien"));
fireEvent.click(await screen.findByRole("option", { name: "bloque" }));
fireEvent.click(within(detail).getByLabelText("Ajouter un lien"));
// The picker excludes the ticket itself; the target is offered.
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
fireEvent.click(await screen.findByLabelText(`select ticket ${b.ref}`));
expect(screen.queryByLabelText(`Sélectionner le ticket ${a.ref}`)).toBeNull();
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${b.ref}`));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, a.ref);
@ -1153,27 +1153,27 @@ describe("SprintManager (#11)", () => {
renderView(ticket, system, agent);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "manage sprints" }),
screen.getByRole("button", { name: /gérer les sprints/i }),
).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: "manage sprints" }));
return screen.findByRole("dialog", { name: "manage sprints" });
fireEvent.click(screen.getByRole("button", { name: /gérer les sprints/i }));
return screen.findByRole("dialog", { name: /gérer les sprints/i });
}
it("creates a sprint", async () => {
const dialog = await openManager();
fireEvent.change(within(dialog).getByLabelText("new sprint name"), {
fireEvent.change(within(dialog).getByLabelText("nom du nouveau sprint"), {
target: { value: "Alpha" },
});
fireEvent.click(within(dialog).getByText("Create sprint"));
fireEvent.click(within(dialog).getByText("Créer le sprint"));
await waitFor(async () => {
const sprints = await ticket.listSprints(PROJECT_ID);
expect(sprints.map((s) => s.name)).toEqual(["Alpha"]);
});
expect(
await within(dialog).findByLabelText("sprint row Alpha"),
await within(dialog).findByLabelText("ligne du sprint Alpha"),
).toBeTruthy();
});
@ -1182,17 +1182,17 @@ describe("SprintManager (#11)", () => {
// Leave the name field empty and create → default "Sprint N" (here N=1).
expect(
(within(dialog).getByLabelText("new sprint name") as HTMLInputElement)
(within(dialog).getByLabelText("nom du nouveau sprint") as HTMLInputElement)
.value,
).toBe("");
fireEvent.click(within(dialog).getByText("Create sprint"));
fireEvent.click(within(dialog).getByText("Créer le sprint"));
await waitFor(async () => {
const sprints = await ticket.listSprints(PROJECT_ID);
expect(sprints.map((s) => s.name)).toEqual(["Sprint 1"]);
});
expect(
await within(dialog).findByLabelText("sprint row Sprint 1"),
await within(dialog).findByLabelText("ligne du sprint Sprint 1"),
).toBeTruthy();
});
@ -1201,10 +1201,10 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.change(
await within(dialog).findByLabelText("rename sprint Old"),
await within(dialog).findByLabelText("renommer le sprint Old"),
{ target: { value: "New" } },
);
fireEvent.click(within(dialog).getByLabelText("save sprint Old"));
fireEvent.click(within(dialog).getByLabelText("enregistrer le sprint Old"));
await waitFor(async () => {
const [s] = await ticket.listSprints(PROJECT_ID);
@ -1218,7 +1218,7 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("move sprint One down"),
await within(dialog).findByLabelText("descendre le sprint One"),
);
await waitFor(async () => {
@ -1236,14 +1236,14 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("delete sprint Doomed"),
await within(dialog).findByLabelText("supprimer le sprint Doomed"),
);
// The confirmation explains tickets are unassigned, not deleted.
const confirm = await screen.findByRole("dialog", {
name: "delete sprint confirmation",
name: "confirmation de suppression du sprint",
});
expect(confirm.textContent).toMatch(/ne seront pas supprimés/i);
fireEvent.click(within(confirm).getByLabelText("confirm delete sprint"));
fireEvent.click(within(confirm).getByLabelText("confirmer la suppression du sprint"));
await waitFor(async () => {
expect(await ticket.listSprints(PROJECT_ID)).toHaveLength(0);
@ -1263,13 +1263,16 @@ describe("SprintManager (#11)", () => {
// Open the add-ticket picker for the Target sprint (multi-select).
fireEvent.click(
await within(dialog).findByLabelText("add ticket to sprint Target"),
await within(dialog).findByLabelText("ajouter un ticket au sprint Target"),
);
const picker = await screen.findByRole("dialog", {
name: /Ajouter des tickets au sprint/,
});
// The picker toggles rows without assigning; confirm applies the batch.
fireEvent.click(await screen.findByLabelText(`select ticket ${t1.ref}`));
fireEvent.click(await screen.findByLabelText(`select ticket ${t2.ref}`));
fireEvent.click(screen.getByLabelText("confirm ticket selection"));
fireEvent.click(await within(picker).findByLabelText(`Sélectionner le ticket ${t1.ref}`));
fireEvent.click(await within(picker).findByLabelText(`Sélectionner le ticket ${t2.ref}`));
fireEvent.click(within(picker).getByLabelText("Confirmer la sélection de tickets"));
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, t1.ref)).sprintId).toBe("s1");
@ -1285,15 +1288,18 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("add ticket to sprint Target"),
await within(dialog).findByLabelText("ajouter un ticket au sprint Target"),
);
const picker = await screen.findByRole("dialog", {
name: /Ajouter des tickets au sprint/,
});
// The backlog ticket is offered; the one already in the sprint is excluded.
expect(
await screen.findByLabelText(`select ticket ${backlog.ref}`),
await within(picker).findByLabelText(`Sélectionner le ticket ${backlog.ref}`),
).toBeTruthy();
expect(
screen.queryByLabelText(`select ticket ${member.ref}`),
within(picker).queryByLabelText(`Sélectionner le ticket ${member.ref}`),
).toBeNull();
});
@ -1305,7 +1311,7 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText(`remove ${t.ref} from sprint`),
await within(dialog).findByLabelText(`retirer ${t.ref} du sprint`),
);
await waitFor(async () => {
@ -1337,16 +1343,16 @@ describe("SprintManager (#11)", () => {
await seedSprintWithOpenAndClosed();
const dialog = await openManager();
const row = await within(dialog).findByLabelText("sprint row Holder");
const row = await within(dialog).findByLabelText("ligne du sprint Holder");
// Both the open and the closed ticket are listed (closed is NOT hidden).
expect(within(row).getByText("Open member")).toBeTruthy();
expect(within(row).getByText("Closed member")).toBeTruthy();
// Each row carries its status + priority badge (scoped to the sprint row so
// the facet-bar labels don't collide).
expect(within(row).getByText("Closed")).toBeTruthy();
expect(within(row).getByText("Open")).toBeTruthy();
expect(within(row).getByText("High")).toBeTruthy();
expect(within(row).getByText("Low")).toBeTruthy();
expect(within(row).getByText("Fermé")).toBeTruthy();
expect(within(row).getByText("Ouvert")).toBeTruthy();
expect(within(row).getByText("Haute")).toBeTruthy();
expect(within(row).getByText("Faible")).toBeTruthy();
});
it("filters the sprint tickets by status via its own facets bar (#37)", async () => {
@ -1357,14 +1363,14 @@ describe("SprintManager (#11)", () => {
expect(within(dialog).getByText("Closed member")).toBeTruthy();
// Constrain the sprint view to Closed only → the open ticket drops out.
fireEvent.click(within(dialog).getByLabelText("filter status Closed"));
fireEvent.click(within(dialog).getByLabelText("Filtrer le statut Fermé"));
await waitFor(() =>
expect(within(dialog).queryByText("Open member")).toBeNull(),
);
expect(within(dialog).getByText("Closed member")).toBeTruthy();
// Clearing the facet brings the open ticket back.
fireEvent.click(within(dialog).getByLabelText("filter status Closed"));
fireEvent.click(within(dialog).getByLabelText("Filtrer le statut Fermé"));
await waitFor(() =>
expect(within(dialog).getByText("Open member")).toBeTruthy(),
);
@ -1376,20 +1382,20 @@ describe("SprintManager (#11)", () => {
renderView(ticket, system, agent);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "manage sprints" }),
screen.getByRole("button", { name: /gérer les sprints/i }),
).toBeTruthy(),
);
// Constrain the MAIN Tickets panel to Open only → the closed ticket leaves
// the main list (there is a single main facets bar on screen at this point).
fireEvent.click(screen.getByLabelText("filter status Open"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Ouvert"));
await waitFor(() => expect(screen.queryByText("Closed member")).toBeNull());
// Open the sprint manager: its own query ignores the main filter, so the
// closed ticket is still shown inside its sprint.
fireEvent.click(screen.getByRole("button", { name: "manage sprints" }));
const dialog = await screen.findByRole("dialog", { name: "manage sprints" });
const row = await within(dialog).findByLabelText("sprint row Holder");
fireEvent.click(screen.getByRole("button", { name: /gérer les sprints/i }));
const dialog = await screen.findByRole("dialog", { name: /gérer les sprints/i });
const row = await within(dialog).findByLabelText("ligne du sprint Holder");
expect(await within(row).findByText("Closed member")).toBeTruthy();
expect(within(row).getByText("Open member")).toBeTruthy();
});