test(web): cover every CheckboxOption/RadioOption behavior
Complète les component tests des deux seuls composants UI génériques
committés (Dialog.tsx est un WIP non commité d'une autre fonctionnalité
en cours — hors scope ici) pour couvrir tout leur comportement, pas
seulement le cas heureux.
CheckboxOption — 4 tests existants (rendu, checked/is-selected, onChange
au clic depuis unchecked, contrôlé) complétés par :
- onChange(false) au clic depuis l'état checked (symétrique du test
existant, qui ne couvrait que checked=false → true)
- fusion du className de l'appelant avec is-selected, dans les deux
sens (juste className, className+is-selected)
- class="" (chaîne vide, pas "false"/"null") quand aucun className
n'est passé et que checked=false — pin le comportement exact du
`.filter(Boolean).join(" ")`
- le clic sur le texte du label (pas seulement l'input) déclenche aussi
onChange — comportement natif du HTML dont la "carte sélectionnable"
de global.scss dépend entièrement
- le span .check-mark est aria-hidden
RadioOption — aucun test avant ce commit. Ajouté en couvrant en plus
ce qui distingue vraiment un radio d'un checkbox :
- name/value posés sur l'input natif
- onChange(value) au clic depuis unchecked
- AUCUN onChange au clic sur un radio déjà checked (contrairement à un
checkbox, un radio natif ne réémet pas `change` si l'état ne change
pas réellement)
- clic sur le label, className/is-selected, aria-hidden — mêmes
scénarios que CheckboxOption
- comportement de groupe mutuellement exclusif : 3 RadioOption
partageant `name="theme"` (mirroring UserPreferencesPage), un seul
sélectionné à la fois, y compris via `input:checked` natif du
navigateur
Non exécutable en local (limitation GPU/sandbox Electron documentée
dans le README, pré-existante) — à vérifier en CI.
This commit is contained in:
parent
37cf6d4dcd
commit
d41b65fff3
2 changed files with 223 additions and 0 deletions
|
|
@ -65,4 +65,76 @@ describe("CheckboxOption", () => {
|
||||||
cy.get("input[type=checkbox]").should("not.be.checked").click();
|
cy.get("input[type=checkbox]").should("not.be.checked").click();
|
||||||
cy.get("input[type=checkbox]").should("be.checked");
|
cy.get("input[type=checkbox]").should("be.checked");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls onChange with false when clicking while already checked", () => {
|
||||||
|
const onChange = cy.stub().as("onChange");
|
||||||
|
cy.mount(
|
||||||
|
<CheckboxOption checked={true} onChange={onChange}>
|
||||||
|
Végétarien
|
||||||
|
</CheckboxOption>,
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get("input[type=checkbox]").click();
|
||||||
|
|
||||||
|
cy.get("@onChange").should("have.been.calledOnceWith", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges the caller's className with the container layout, alongside is-selected", () => {
|
||||||
|
cy.mount(
|
||||||
|
<CheckboxOption checked={false} onChange={() => {}} className="allergy-select__option">
|
||||||
|
Végétarien
|
||||||
|
</CheckboxOption>,
|
||||||
|
);
|
||||||
|
cy.get("label")
|
||||||
|
.should("have.class", "allergy-select__option")
|
||||||
|
.and("not.have.class", "is-selected");
|
||||||
|
|
||||||
|
cy.mount(
|
||||||
|
<CheckboxOption checked={true} onChange={() => {}} className="allergy-select__option">
|
||||||
|
Végétarien
|
||||||
|
</CheckboxOption>,
|
||||||
|
);
|
||||||
|
cy.get("label").should("have.class", "allergy-select__option").and("have.class", "is-selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no className at all when the caller doesn't pass one", () => {
|
||||||
|
cy.mount(
|
||||||
|
<CheckboxOption checked={false} onChange={() => {}}>
|
||||||
|
Végétarien
|
||||||
|
</CheckboxOption>,
|
||||||
|
);
|
||||||
|
// `[className, checked && "is-selected"].filter(Boolean).join(" ")` with
|
||||||
|
// both falsy collapses to "" — worth pinning down since a stray
|
||||||
|
// "false"/"null" string in `class` would be a real (if harmless-looking)
|
||||||
|
// regression.
|
||||||
|
cy.get("label").should("have.attr", "class", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles when the click lands on the label text, not just the input itself", () => {
|
||||||
|
// The label wraps the input (native HTML forwards the click), which is
|
||||||
|
// what actually makes the whole row clickable — not just a tiny
|
||||||
|
// checkbox hitbox. This is real browser behavior, not something the
|
||||||
|
// component's own code implements, but it's exactly the contract the
|
||||||
|
// "selectable card" look (global.scss) relies on, so it's worth pinning
|
||||||
|
// down here rather than trusting it silently.
|
||||||
|
const onChange = cy.stub().as("onChange");
|
||||||
|
cy.mount(
|
||||||
|
<CheckboxOption checked={false} onChange={onChange}>
|
||||||
|
Végétarien
|
||||||
|
</CheckboxOption>,
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.contains("label", "Végétarien").click();
|
||||||
|
|
||||||
|
cy.get("@onChange").should("have.been.calledOnceWith", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the check-mark decoration as aria-hidden, so screen readers only announce the checkbox itself", () => {
|
||||||
|
cy.mount(
|
||||||
|
<CheckboxOption checked={false} onChange={() => {}}>
|
||||||
|
Végétarien
|
||||||
|
</CheckboxOption>,
|
||||||
|
);
|
||||||
|
cy.get("span.check-mark").should("have.attr", "aria-hidden", "true");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
151
apps/web/cypress/component/RadioOption.cy.tsx
Normal file
151
apps/web/cypress/component/RadioOption.cy.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { RadioOption } from "../../src/components/ui/Radio";
|
||||||
|
|
||||||
|
// The `type="radio"` sibling of CheckboxOption.cy.tsx — same "selectable
|
||||||
|
// card" markup, but exercised for what actually differs about a radio
|
||||||
|
// input: the mandatory `name`/`value` pair and the mutually-exclusive
|
||||||
|
// group behavior that's the whole reason to reach for radio over checkbox.
|
||||||
|
|
||||||
|
describe("RadioOption", () => {
|
||||||
|
it("renders its label content", () => {
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption name="theme" value="dark" checked={false} onChange={() => {}}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.contains("label", "Sombre").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the native input's name and value", () => {
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption name="theme" value="dark" checked={false} onChange={() => {}}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get("input[type=radio]")
|
||||||
|
.should("have.attr", "name", "theme")
|
||||||
|
.and("have.attr", "value", "dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects the checked prop on the native input, and the is-selected class", () => {
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption name="theme" value="dark" checked={false} onChange={() => {}}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
cy.get("input[type=radio]").should("not.be.checked");
|
||||||
|
cy.get("label").should("not.have.class", "is-selected");
|
||||||
|
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption name="theme" value="dark" checked={true} onChange={() => {}}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
cy.get("input[type=radio]").should("be.checked");
|
||||||
|
cy.get("label").should("have.class", "is-selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange with its own value when clicked while unchecked", () => {
|
||||||
|
const onChange = cy.stub().as("onChange");
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption name="theme" value="dark" checked={false} onChange={onChange}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get("input[type=radio]").click();
|
||||||
|
|
||||||
|
cy.get("@onChange").should("have.been.calledOnceWith", "dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire onChange again when clicking a radio that's already checked", () => {
|
||||||
|
// Native radio inputs only emit a `change` event when their checked
|
||||||
|
// state actually flips — clicking an already-selected option in a
|
||||||
|
// group is a no-op, unlike a checkbox which always toggles.
|
||||||
|
const onChange = cy.stub().as("onChange");
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption name="theme" value="dark" checked={true} onChange={onChange}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get("input[type=radio]").click();
|
||||||
|
|
||||||
|
cy.get("@onChange").should("not.have.been.called");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles when the click lands on the label text, not just the input itself", () => {
|
||||||
|
const onChange = cy.stub().as("onChange");
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption name="theme" value="dark" checked={false} onChange={onChange}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.contains("label", "Sombre").click();
|
||||||
|
|
||||||
|
cy.get("@onChange").should("have.been.calledOnceWith", "dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges the caller's className with the container layout, alongside is-selected", () => {
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption
|
||||||
|
name="theme"
|
||||||
|
value="dark"
|
||||||
|
checked={true}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="theme-select__option"
|
||||||
|
>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
cy.get("label").should("have.class", "theme-select__option").and("have.class", "is-selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the check-mark decoration as aria-hidden, so screen readers only announce the radio itself", () => {
|
||||||
|
cy.mount(
|
||||||
|
<RadioOption name="theme" value="dark" checked={false} onChange={() => {}}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>,
|
||||||
|
);
|
||||||
|
cy.get("span.check-mark").should("have.attr", "aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behaves as a mutually-exclusive group when several options share the same name", () => {
|
||||||
|
// A stateful wrapper mirroring UserPreferencesPage's theme picker — the
|
||||||
|
// one real caller — mounting 3 RadioOptions that share `name="theme"`
|
||||||
|
// and one `value` of state between them.
|
||||||
|
function ThemeGroup() {
|
||||||
|
const [theme, setTheme] = useState<"system" | "light" | "dark">("system");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RadioOption name="theme" value="system" checked={theme === "system"} onChange={setTheme}>
|
||||||
|
Système
|
||||||
|
</RadioOption>
|
||||||
|
<RadioOption name="theme" value="light" checked={theme === "light"} onChange={setTheme}>
|
||||||
|
Clair
|
||||||
|
</RadioOption>
|
||||||
|
<RadioOption name="theme" value="dark" checked={theme === "dark"} onChange={setTheme}>
|
||||||
|
Sombre
|
||||||
|
</RadioOption>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cy.mount(<ThemeGroup />);
|
||||||
|
|
||||||
|
cy.contains("label", "Système").should("have.class", "is-selected");
|
||||||
|
cy.contains("label", "Clair").should("not.have.class", "is-selected");
|
||||||
|
cy.contains("label", "Sombre").should("not.have.class", "is-selected");
|
||||||
|
|
||||||
|
cy.contains("label", "Sombre").click();
|
||||||
|
|
||||||
|
cy.contains("label", "Sombre").should("have.class", "is-selected");
|
||||||
|
cy.contains("label", "Système").should("not.have.class", "is-selected");
|
||||||
|
cy.contains("label", "Clair").should("not.have.class", "is-selected");
|
||||||
|
// The native `name` grouping also keeps the browser's own radio
|
||||||
|
// semantics honest — only one input in the group can be `:checked`.
|
||||||
|
cy.get("input[type=radio]:checked").should("have.length", 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue