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.
164 lines
5.0 KiB
TypeScript
164 lines
5.0 KiB
TypeScript
/**
|
|
* LD — design-system component tests. Pure UI, rendered with React Testing
|
|
* Library; no backend, no gateways. We assert behaviour and accessibility
|
|
* (roles, names, aria-*) rather than exact Tailwind classes, so the visual
|
|
* design can evolve without breaking the suite.
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
|
|
import { Button } from "./Button";
|
|
import { Input } from "./Input";
|
|
import { Field } from "./Field";
|
|
import { SmallDropdown } from "./SmallDropdown";
|
|
import { Tabs } from "./Tabs";
|
|
|
|
describe("Button", () => {
|
|
it("renders its label and fires onClick", () => {
|
|
const onClick = vi.fn();
|
|
render(<Button onClick={onClick}>Save</Button>);
|
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
expect(onClick).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("is disabled and shows a spinner while loading", () => {
|
|
const onClick = vi.fn();
|
|
render(
|
|
<Button loading onClick={onClick}>
|
|
Save
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: /Save/ }) as HTMLButtonElement;
|
|
expect(btn.disabled).toBe(true);
|
|
expect(screen.getByRole("status")).toBeTruthy(); // spinner
|
|
fireEvent.click(btn);
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("defaults to type=button to avoid accidental form submits", () => {
|
|
render(<Button>Go</Button>);
|
|
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe(
|
|
"button",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Input", () => {
|
|
it("flags aria-invalid when invalid", () => {
|
|
render(<Input aria-label="email" invalid />);
|
|
expect(screen.getByLabelText("email").getAttribute("aria-invalid")).toBe("true");
|
|
});
|
|
|
|
it("is not aria-invalid by default", () => {
|
|
render(<Input aria-label="email" />);
|
|
expect(screen.getByLabelText("email").getAttribute("aria-invalid")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Field", () => {
|
|
it("associates the label with the control via id", () => {
|
|
render(
|
|
<Field label="Project name">
|
|
{({ id, describedBy }) => (
|
|
<input id={id} aria-describedby={describedBy} />
|
|
)}
|
|
</Field>,
|
|
);
|
|
// getByLabelText resolves the label→control association.
|
|
expect(screen.getByLabelText("Project name")).toBeTruthy();
|
|
});
|
|
|
|
it("announces an error and wires aria-describedby", () => {
|
|
render(
|
|
<Field label="Root" error="must be absolute">
|
|
{({ id, describedBy }) => (
|
|
<input id={id} aria-describedby={describedBy} />
|
|
)}
|
|
</Field>,
|
|
);
|
|
const alert = screen.getByRole("alert");
|
|
expect(alert.textContent).toBe("must be absolute");
|
|
const input = screen.getByLabelText("Root");
|
|
expect(input.getAttribute("aria-describedby")).toBe(alert.id);
|
|
});
|
|
});
|
|
|
|
describe("Tabs", () => {
|
|
const items = [
|
|
{ id: "a", label: "alpha" },
|
|
{ id: "b", label: "beta" },
|
|
];
|
|
|
|
it("marks the active tab with aria-selected and reports selection", () => {
|
|
const onSelect = vi.fn();
|
|
render(<Tabs items={items} value="a" onSelect={onSelect} />);
|
|
|
|
const tabs = screen.getAllByRole("tab");
|
|
expect(tabs).toHaveLength(2);
|
|
const [alpha, beta] = tabs;
|
|
expect(alpha.getAttribute("aria-selected")).toBe("true");
|
|
expect(beta.getAttribute("aria-selected")).toBe("false");
|
|
|
|
fireEvent.click(beta);
|
|
expect(onSelect).toHaveBeenCalledWith("b");
|
|
});
|
|
|
|
it("renders a close control per tab only when onClose is given", () => {
|
|
const onClose = vi.fn();
|
|
const { rerender } = render(
|
|
<Tabs items={items} value="a" onSelect={() => {}} />,
|
|
);
|
|
expect(screen.queryByRole("button", { name: "fermer alpha" })).toBeNull();
|
|
|
|
rerender(<Tabs items={items} value="a" onSelect={() => {}} onClose={onClose} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "fermer beta" }));
|
|
expect(onClose).toHaveBeenCalledWith("b");
|
|
});
|
|
});
|
|
|
|
describe("SmallDropdown", () => {
|
|
const options = [
|
|
{ value: "", label: "None" },
|
|
{ value: "a", label: "Alpha" },
|
|
{ value: "b", label: "Beta", disabled: true },
|
|
];
|
|
|
|
it("opens a listbox and reports the selected value", () => {
|
|
const onChange = vi.fn();
|
|
render(
|
|
<SmallDropdown
|
|
aria-label="test dropdown"
|
|
value=""
|
|
options={options}
|
|
onChange={onChange}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByLabelText("test dropdown"));
|
|
fireEvent.click(screen.getByRole("option", { name: "Alpha" }));
|
|
|
|
expect(onChange).toHaveBeenCalledWith("a");
|
|
expect(screen.queryByRole("listbox")).toBeNull();
|
|
});
|
|
|
|
it("supports keyboard selection and ignores disabled options", () => {
|
|
const onChange = vi.fn();
|
|
render(
|
|
<SmallDropdown
|
|
aria-label="test dropdown"
|
|
value=""
|
|
options={options}
|
|
onChange={onChange}
|
|
/>,
|
|
);
|
|
|
|
const trigger = screen.getByLabelText("test dropdown");
|
|
fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
|
fireEvent.keyDown(document, { key: "ArrowDown" });
|
|
fireEvent.keyDown(document, { key: "Enter" });
|
|
|
|
expect(onChange).toHaveBeenCalledWith("a");
|
|
});
|
|
});
|