Merge pull request #14 from kyuno053/fix/selectable-controls-and-deterministic-tests

Tests Planning deterministes + refonte du style radio/checkbox
This commit is contained in:
kyuno053 2026-08-17 22:44:33 +02:00 committed by GitHub
commit f63f9af544
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 233 additions and 118 deletions

View file

@ -1,12 +1,14 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { DateTime } from "@batch-cooking/date-tools";
import { Given, Then, When } from "@cucumber/cucumber"; import { Given, Then, When } from "@cucumber/cucumber";
import { prisma } from "../../src/db/prisma.js"; import { prisma } from "../../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
import type { CustomWorld } from "../support/world.js"; import type { CustomWorld } from "../support/world.js";
/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to "today". */ /** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to the fixed test "today" (see `TEST_REFERENCE_DATE`). */
When("I request the current planning", async function (this: CustomWorld) { When("I request the current planning", async function (this: CustomWorld) {
this.response = await this.agent.get("/planning").query({ date: DateTime.utc().toISODate() }); this.response = await this.agent
.get("/planning")
.query({ date: TEST_REFERENCE_DATE.toISODate() });
}); });
Then("the current planning response should be empty", function (this: CustomWorld) { Then("the current planning response should be empty", function (this: CustomWorld) {
@ -27,16 +29,11 @@ Given(
const houseId: number = houseRes.body.id; const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: recipeName } }); const recipe = await prisma.recipe.create({ data: { name: recipeName } });
const today = new Date();
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {
houseId, houseId,
startDate: new Date( startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2), finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
),
finishDate: new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2),
),
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({

View file

@ -0,0 +1,15 @@
import { DateTime } from "@batch-cooking/date-tools";
/**
* The fixed "today" every date-sensitive test builds its fixtures and
* queries around, instead of the real system clock (`new Date()` /
* `DateTime.utc()`). Reading the real clock made those tests
* non-deterministic: behavior could shift depending on which day/time they
* happened to run (e.g. a UTC-midnight boundary behaving differently right
* around real midnight), and made it impossible to reliably target a
* specific day-of-week. Any fixed UTC date works here; this one has no
* special meaning tests that care about a specific weekday should derive
* it from this constant (e.g. `.set({ weekday: 1 })`) rather than hardcode
* one that happens to match today by coincidence.
*/
export const TEST_REFERENCE_DATE = DateTime.fromISO("2026-06-15", { zone: "utc" });

View file

@ -1,10 +1,11 @@
import { DateTime } from "@batch-cooking/date-tools"; import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { expect } from "chai"; import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */ /** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
@ -19,9 +20,9 @@ function buildSignupPayload(): SignupInput {
}; };
} }
/** Today, as the `YYYY-MM-DD` string `GET /planning`'s `?date=` expects. */ /** The fixed test "today", as the `YYYY-MM-DD` string `GET /planning`'s `?date=` expects. */
function today(): string { function today(): string {
return isoDate(DateTime.utc()); return isoDate(TEST_REFERENCE_DATE);
} }
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */ /** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */
@ -97,16 +98,11 @@ describe("Planning", () => {
const houseId: number = houseRes.body.id; const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } }); const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
const now = new Date();
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {
houseId, houseId,
startDate: new Date( startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 2), finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
),
finishDate: new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2),
),
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({
@ -150,7 +146,7 @@ describe("Planning", () => {
const houseId: number = houseRes.body.id; const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles" } }); const recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles" } });
const nextWeek = DateTime.utc().plus({ weeks: 1 }); const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {
houseId, houseId,

View file

@ -35,16 +35,24 @@ export function AllergySelect({ legend, allergies, value, onChange }: AllergySel
return ( return (
<fieldset className="allergy-select"> <fieldset className="allergy-select">
<legend>{legend}</legend> <legend>{legend}</legend>
{allergies.map((allergy) => ( {allergies.map((allergy) => {
<label key={allergy.id} className="allergy-select__option"> const checked = value.includes(allergy.id);
<input return (
type="checkbox" <label
checked={value.includes(allergy.id)} key={allergy.id}
onChange={() => toggle(allergy.id)} // `is-selected` (not a `:has(:checked)` CSS rule) drives the
/> // selected look — chaining `:has(...):has(:checked)` to react to
// a *state* change (rather than a DOM mutation) turned out to be
// unreliable, so this stays a plain, always-correct React class
// instead of relying on CSS to derive it.
className={`allergy-select__option${checked ? " is-selected" : ""}`}
>
<input type="checkbox" checked={checked} onChange={() => toggle(allergy.id)} />
<span className="check-mark" aria-hidden="true" />
{allergy.name} {allergy.name}
</label> </label>
))} );
})}
</fieldset> </fieldset>
); );
} }

View file

@ -18,7 +18,11 @@ label {
margin-top: var(--space-sm); margin-top: var(--space-sm);
} }
input, // Excludes checkbox/radio those get their own deliberate, fixed-size
// appearance from global.scss instead of stretching to the field width
// like a text/select input (this used to bleed onto them unscoped, which
// is why they used to render oversized and misaligned with their label).
input:not([type="checkbox"]):not([type="radio"]),
select { select {
width: 100%; width: 100%;
padding: var(--space-sm); padding: var(--space-sm);
@ -57,19 +61,15 @@ select {
font-weight: 600; font-weight: 600;
} }
// The option row's own look (bordered "card", checked state, checkmark)
// is entirely global.scss's — this only overrides what doesn't fit a
// grid cell: the block/margin-top label rule above (this wraps an inline
// checkbox + text pair, not a field caption above an input) and the
// heavier default label weight (checked rows re-bold themselves via
// global.scss; unchecked ones should read as plain body text).
&__option { &__option {
display: flex;
align-items: center;
gap: var(--space-xs);
// Overrides the block/margin-top label rule above this label wraps
// an inline checkbox + text pair, not a field caption above an input.
margin-top: 0; margin-top: 0;
font-weight: 400; font-weight: 400;
font-size: var(--font-size-base); font-size: var(--font-size-base);
cursor: pointer;
input[type="checkbox"] {
width: auto;
}
} }
} }

View file

@ -43,18 +43,28 @@ export function UserPreferencesPage() {
<div className="settings-page__section"> <div className="settings-page__section">
<fieldset className="theme-select"> <fieldset className="theme-select">
<legend>{t("userPreferences.themeLabel")}</legend> <legend>{t("userPreferences.themeLabel")}</legend>
{THEME_PREFERENCES.map((option) => ( {THEME_PREFERENCES.map((option) => {
<label key={option} className="theme-select__option"> const checked = theme === option;
return (
<label
key={option}
// `is-selected` (not a `:has(:checked)` CSS rule) drives the
// selected look — see AllergySelect.tsx for why this stays
// in JS rather than pure CSS.
className={`theme-select__option${checked ? " is-selected" : ""}`}
>
<input <input
type="radio" type="radio"
name="theme" name="theme"
value={option} value={option}
checked={theme === option} checked={checked}
onChange={() => handleChange(option)} onChange={() => handleChange(option)}
/> />
<span className="check-mark" aria-hidden="true" />
{t(`userPreferences.theme.${option}`)} {t(`userPreferences.theme.${option}`)}
</label> </label>
))} );
})}
</fieldset> </fieldset>
{saveState === "saving" && <p className="settings-page__saving">{t("common.saving")}</p>} {saveState === "saving" && <p className="settings-page__saving">{t("common.saving")}</p>}

View file

@ -152,7 +152,11 @@ button.settings-page__link-button {
// Theme choice (UserPreferencesPage) a plain radio group, no fieldset/ // Theme choice (UserPreferencesPage) a plain radio group, no fieldset/
// legend styling exists elsewhere yet to reuse (AllergySelect's `.allergy- // legend styling exists elsewhere yet to reuse (AllergySelect's `.allergy-
// select` in profile-forms.scss is checkbox-grid specific). // select` in profile-forms.scss is checkbox-grid specific). The option
// rows' own look (bordered "card", checked state, checkmark) is entirely
// global.scss's — this only resets the fieldset chrome; the generic
// `label { margin-top }` rule (profile-forms.scss) already stacks the
// rows with breathing room between them.
.theme-select { .theme-select {
border: none; border: none;
padding: 0; padding: 0;
@ -163,12 +167,4 @@ button.settings-page__link-button {
font-weight: 600; font-weight: 600;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
&__option {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) 0;
cursor: pointer;
}
} }

View file

@ -53,3 +53,96 @@ h1 {
outline: 2px solid var(--color-accent); outline: 2px solid var(--color-accent);
outline-offset: 2px; outline-offset: 2px;
} }
// Checkbox/radio appearance, app-wide "selectable card" style: the native
// control itself is visually hidden (still real, focusable and
// screen-reader-visible see the `input[type=...]` rule below, not
// `display: none`) and the whole label row it lives in becomes the
// interactive surface instead: a flat bordered box that fills in with a
// tinted background + primary border once selected, with a checkmark
// fading in on the trailing edge.
//
// The base (unselected) look below is detected structurally with `:has()`
// safe, since "does this label contain a checkbox/radio" never changes
// after mount. The *selected* look is instead driven by the `is-selected`
// class each caller (AllergySelect.tsx, UserPreferencesPage.tsx) toggles
// in JS from the same boolean it already passes to `checked` chaining a
// second `:has(:checked)` to react to that live state turned out to be
// unreliable across browsers, so this only needs one always-true `:has()`.
//
// Every checkbox/radio in the app goes through this one place (the allergy
// grid, the theme picker, anywhere future) rather than each feature styling
// its own see profile-forms.scss / settings-pages.scss, which only
// arrange these within their own layout (grid vs. stacked list) and
// intentionally don't re-style the control/label look itself.
label:has(> input[type="checkbox"]),
label:has(> input[type="radio"]) {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
// Overrides the generic `label { font-weight: 600 }` base rule
// (profile-forms.scss) without this, an *unselected* row reads just as
// bold as a selected one (only `.allergy-select__option` happened to set
// its own 400 already; `.theme-select__option` didn't, so its rows were
// all permanently bold until this was centralized here).
font-weight: 400;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
&.is-selected {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
color: var(--color-primary);
font-weight: 600;
}
&:has(:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
// The control itself is removed from the visual flow hidden the
// "sr-only" way (not `display: none`) so it stays focusable/tabbable and
// announced correctly by screen readers; the label above carries the
// entire visible selected/unchecked look.
input[type="checkbox"],
input[type="radio"] {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
// The checkmark a real element (see AllergySelect.tsx /
// UserPreferencesPage.tsx) shown via the same `is-selected` class as the
// label's own look above, not a separate CSS-only trigger. Scaled in from
// nothing so toggling has a bit of motion. Same mark for both checkbox and
// radio: one consistent "selected" language app-wide rather than a
// checkmark here and a dot there.
.check-mark {
flex: none;
margin-left: auto;
width: 0.9rem;
height: 0.9rem;
background: var(--color-primary);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
transform: scale(0);
transition: transform 0.1s ease;
}
.is-selected .check-mark {
transform: scale(1);
}