Files
benevoles/frontend/scripts/smoke.tsx
T
AdamThomasandClaude Sonnet 5 8b9c0ac498 Corrige la grille des besoins : postes manquants au-delà du premier
Chaque cellule de la grille (label de poste, en-têtes, créneaux, cases
vides) recevait sa position via l'ordre d'émission des enfants, en
comptant sur le fait que chaque ligne totalise exactement le nombre de
tranches de 30 min d'un jour pour que l'auto-flow CSS reste aligné. Un
poste dont les créneaux ne tombaient pas exactement sur ce total (deux
créneaux adjacents mais non alignés sur 30 min, courant depuis la
création de créneaux à la main) décalait tous les postes suivants —
leur nom de poste n'apparaissait plus sur la bonne ligne.

Chaque cellule reçoit maintenant sa position de grille explicitement
(gridColumn/gridRow calculés), immunisé contre ce genre de décalage :
l'anomalie d'un poste reste locale à sa propre ligne.

Nouveau test de non-régression (scripts/smoke.tsx:checkGridLayout) :
inspecte les gridRow réellement rendus plutôt que la simple absence de
crash — un décalage CSS n'est pas détectable par un rendu SSR ordinaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 19:04:38 +02:00

436 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Vérifs headless : flux d'auth du mock + rendu de chaque écran.
// Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx
import { createElement, type ReactNode } from 'react';
import { renderToString } from 'react-dom/server';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, RouterProvider, createMemoryRouter } from 'react-router-dom';
import {
ApiError,
DEMO_COORDINATOR_EMAIL,
DEMO_COORDINATOR_PASSWORD,
EDITION_ID,
api,
devMagicToken,
setAuthToken,
} from '@/api';
import type { AccountRead } from '@/api/schema';
import { queryKeys } from '@/api/hooks';
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
import { routes } from '@/app/router';
import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning';
import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage';
import { FormBuilderPage } from '@/pages/FormBuilderPage';
import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage';
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
const e = EDITION_ID;
let failures = 0;
const fail = (label: string, err: unknown) => {
failures += 1;
console.error(`FAIL ${label}`, err);
};
let coordinator: AccountRead | null = null;
let volunteerAccount: AccountRead | null = null;
let leadAccount: AccountRead | null = null;
let coordinatorToken = '';
async function checkAuth() {
let badRejected = 'non';
try {
await api.auth.login(e, { email: DEMO_COORDINATOR_EMAIL, password: 'faux' });
} catch (err) {
badRejected = err instanceof ApiError ? err.code : String(err);
}
const session = await api.auth.login(e, {
email: DEMO_COORDINATOR_EMAIL,
password: DEMO_COORDINATOR_PASSWORD,
});
coordinator = session.account;
coordinatorToken = session.access_token;
setAuthToken(coordinatorToken);
const me = await api.auth.me();
const vols = await api.volunteers.list(e);
const volEmail = vols.find((v) => v.email)?.email ?? '';
await api.auth.requestMagicLink(e, { email: volEmail });
const raw = devMagicToken(volEmail);
volunteerAccount = raw ? (await api.auth.verifyMagicLink(e, { token: raw })).account : null;
await api.auth.requestMagicLink(e, { email: 'referent1@lessonsdautomne.fr' });
const leadRaw = devMagicToken('referent1@lessonsdautomne.fr');
leadAccount = leadRaw ? (await api.auth.verifyMagicLink(e, { token: leadRaw })).account : null;
setAuthToken(coordinatorToken);
console.log(
JSON.stringify(
{
badPasswordRejectedWith: badRejected,
loginRole: session.account.role,
meRole: me.role,
magicVolunteerRole: volunteerAccount?.role,
magicVolunteerLinked: Boolean(volunteerAccount?.volunteer_id),
magicLeadRole: leadAccount?.role,
},
null,
2,
),
);
}
/** Exerce le formulaire public de bout en bout (mode mock) : configuration,
* lecture publique, soumission, vœux (max 2 postes, un artiste par jour),
* et le bénévole créé doit apparaître dans la liste de la coordination. */
async function checkPublicForm() {
const positions = await api.positions.list(e);
const artists = await api.artists.list(e);
const beforeCount = (await api.volunteers.list(e)).length;
await api.forms.setFields(e, {
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT'],
});
const config = await api.forms.getPublicConfig(e);
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
if (config.positions.length !== positions.length) {
throw new Error('formulaire public : postes manquants dans la config publique');
}
if (config.has_logo !== false || config.description !== null || config.yapla_url !== null) {
throw new Error('formulaire public : logo/description/lien Yapla devraient être vides par défaut');
}
// Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des
// API navigateur — non testables sous Node (voir scripts/smoke.tsx tel
// qu'exécuté ici) ; seul le texte d'introduction et le lien Yapla sont
// vérifiables ici.
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
const configWithDescription = await api.forms.getPublicConfig(e);
if (configWithDescription.description !== 'Bienvenue au festival de test !') {
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
}
let invalidYaplaUrlRejectedWith = 'non';
try {
await api.forms.setOpen(e, { yapla_url: 'yapla.com/exemple' });
} catch (err) {
invalidYaplaUrlRejectedWith = err instanceof ApiError ? err.code : String(err);
}
await api.forms.setOpen(e, { yapla_url: 'https://www.yapla.com/fr/exemple' });
const configWithYapla = await api.forms.getPublicConfig(e);
if (configWithYapla.yapla_url !== 'https://www.yapla.com/fr/exemple') {
throw new Error('formulaire public : le lien Yapla ne revient pas correctement');
}
const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date);
let tooManyArtistsRejectedWith = 'non';
if (sameDay.length >= 2) {
try {
await api.forms.submitPublic(e, {
first_name: 'Test',
last_name: 'Smoke',
hcaptcha_token: 'dev',
artist_wish_ids: [sameDay[0].id, sameDay[1].id],
});
} catch (err) {
tooManyArtistsRejectedWith = err instanceof ApiError ? err.code : String(err);
}
}
let tooManyPositionsRejectedWith = 'non';
if (positions.length >= 3) {
try {
await api.forms.submitPublic(e, {
first_name: 'Test',
last_name: 'Smoke',
hcaptcha_token: 'dev',
position_wish_ids: positions.slice(0, 3).map((p) => p.id),
});
} catch (err) {
tooManyPositionsRejectedWith = err instanceof ApiError ? err.code : String(err);
}
}
const result = await api.forms.submitPublic(e, {
first_name: 'test',
last_name: 'smoke',
hcaptcha_token: 'dev',
email: 'test.smoke@example.org',
comment: 'Créé par le smoke test',
position_wish_ids: positions.slice(0, 2).map((p) => p.id),
artist_wish_ids: artists[0] ? [artists[0].id] : [],
});
const created = await api.volunteers.get(e, result.volunteer_id);
const afterCount = (await api.volunteers.list(e)).length;
console.log(
JSON.stringify(
{
formOpenByDefault: config.is_open,
descriptionRoundTrips: configWithDescription.description,
invalidYaplaUrlRejectedWith,
yaplaUrlRoundTrips: configWithYapla.yapla_url,
tooManyPositionsRejectedWith,
tooManyArtistsRejectedWith,
createdVolunteerName: `${created.first_name} ${created.last_name}`,
createdVolunteerAppearsInList: afterCount === beforeCount + 1,
},
null,
2,
),
);
}
/** Feuilles de poste PDF (mode mock) : la sélection filtre bien, l'absence
* de créneau/poste rejette avec le bon code. */
async function checkPositionSheets() {
const positions = await api.positions.list(e);
const first = positions[0];
if (!first) throw new Error('feuilles de poste : aucun poste en démo pour tester');
const blob = await api.positionSheets.generate(e, { position_ids: [first.id] });
if (!(blob instanceof Blob) || blob.size === 0) {
throw new Error('feuilles de poste : PDF vide');
}
let noPositionsRejectedWith = 'non';
try {
await api.positionSheets.generate(e, {
position_ids: ['00000000-0000-0000-0000-000000000000'],
});
} catch (err) {
noPositionsRejectedWith = err instanceof ApiError ? err.code : String(err);
}
console.log(
JSON.stringify({ pdfBytes: blob.size, noPositionsRejectedWith }, null, 2),
);
}
/** Créneaux (mode mock) : un poste créé ne génère plus aucun créneau ; la
* création manuelle à horaires libres fonctionne toujours ; « générer les
* créneaux » (créneaux types) reste disponible en option ; un créneau à
* horaires non alignés (19:15–22:45) se place bien dans la grille demi-heure
* (`buildTimeGrid`/`placeInTimeGrid`), arrondi à la tranche englobante. */
async function checkShiftCreation() {
const position = await api.positions.create(e, { name: 'Smoke poste', category: 'OTHER' });
const shiftsAfterCreate = await api.shifts.list(e, { positionId: position.id });
const edition = await api.editions.get(e);
const shift = await api.shifts.create(e, {
position_id: position.id,
starts_at: `${edition.start_date}T19:15:00`,
ends_at: `${edition.start_date}T22:45:00`,
min_staff: 1,
ideal_staff: 2,
});
const generated = await api.positions.generateShifts(e, position.id);
// Deuxième créneau adjacent au premier mais non aligné sur 30 min (fin à
// 22:45, comme le premier) : arrondis à la tranche englobante, les deux
// créneaux se chevauchent légèrement dans la grille (voir GrillePage.tsx,
// `PositionRow`) — ne doit jamais planter le rendu ni décaler les postes
// suivants (régression corrigée : la grille positionnait ses cellules par
// ordre d'émission plutôt qu'explicitement, un poste dont les créneaux ne
// totalisaient pas exactement le nombre de tranches d'un jour décalait
// tous les postes suivants).
await api.shifts.create(e, {
position_id: position.id,
starts_at: `${edition.start_date}T22:45:00`,
ends_at: `${edition.start_date}T23:15:00`,
min_staff: 1,
ideal_staff: 1,
});
const grid = buildTimeGrid([shift]);
const day = grid[0];
const placement = day ? placeInTimeGrid(grid, [shift]).get(day.date)?.[0] : undefined;
console.log(
JSON.stringify(
{
shiftsAfterPositionCreate: shiftsAfterCreate.length,
manualShiftTimes: `${shift.starts_at}–${shift.ends_at}`,
generateShiftsStillOptIn: generated.created,
gridSlotCount: day?.slotLabels.length,
gridFirstSlot: day?.slotLabels[0],
placementSpan: placement?.columnSpan,
},
null,
2,
),
);
if (shiftsAfterCreate.length !== 0) {
throw new Error('création de poste : ne devrait générer aucun créneau automatiquement');
}
if (generated.created <= 0) {
throw new Error('generate-shifts : devrait rester disponible en option');
}
// 19:15–22:45 (3 h 30) arrondi aux tranches de 30 min englobantes
// (19:00–23:00) = 8 tranches.
if (day?.slotLabels[0] !== '19:00' || placement?.columnSpan !== 8) {
throw new Error('grille demi-heure : placement inattendu pour un créneau non aligné');
}
}
/**
* Grille des besoins : chaque poste affiché doit occuper sa propre ligne de
* grille CSS (`gridRow` explicite, voir `GrillePage.tsx:PositionRow`).
* Régression testée : avant le passage à un positionnement explicite, un
* poste dont les créneaux ne totalisaient pas exactement le nombre de
* tranches de 30 min d'un jour (deux créneaux adjacents mais non alignés
* sur 30 min, par ex. — cas courant depuis la création de créneaux à la
* main) décalait, par un pur effet de bord CSS (auto-flow), tous les postes
* suivants — leur nom de poste n'apparaissait alors plus sur sa propre
* ligne. Purement visuel : un rendu SSR ordinaire ne l'aurait pas détecté,
* d'où cette vérification directe des lignes de grille attribuées.
*/
async function checkGridLayout() {
if (!coordinator) throw new Error('grille : pas de session coordination pour ce test');
const positions = await api.positions.list(e);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await prefetchCore(client);
const html = renderToString(
createElement(
QueryClientProvider,
{ client },
createElement(
AuthContext.Provider,
{ value: authValue(coordinator) },
createElement(MemoryRouter, null, createElement(GrillePage)),
),
),
);
const rowNumbers = [...html.matchAll(/grid-row:\s*(\d+)/g)].map((m) => Number(m[1]));
const uniqueRows = [...new Set(rowNumbers)].sort((a, b) => a - b);
// 2 lignes d'en-tête (jours, puis tranches) + une par poste affiché,
// strictement consécutives (1, 2, 3, … sans trou ni doublon).
const expected = Array.from({ length: positions.length + 2 }, (_, i) => i + 1);
const sequential = uniqueRows.length === expected.length
&& uniqueRows.every((row, i) => row === expected[i]);
console.log(
JSON.stringify(
{ positionsCount: positions.length, gridRowsUsed: uniqueRows.length, sequential },
null,
2,
),
);
if (!sequential) {
throw new Error(
`grille des besoins : lignes de grille ${JSON.stringify(uniqueRows)}, attendu ${JSON.stringify(expected)}`,
);
}
}
async function prefetchCore(client: QueryClient) {
await Promise.all([
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.shifts(), queryFn: () => api.shifts.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.assignments(), queryFn: () => api.assignments.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(e) }),
]);
}
function authValue(account: AccountRead): AuthContextValue {
return {
status: 'authed',
account,
loginWithPassword: async () => account,
requestMagicLink: async () => {},
verifyMagicLink: async () => account,
logout: () => {},
};
}
async function renderProtected(
label: string,
account: AccountRead,
node: ReactNode,
extra?: (client: QueryClient) => Promise<void>,
) {
try {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await prefetchCore(client);
if (extra) await extra(client);
const html = renderToString(
createElement(
QueryClientProvider,
{ client },
createElement(
AuthContext.Provider,
{ value: authValue(account) },
createElement(MemoryRouter, null, node),
),
),
);
console.log(`ok ${label.padEnd(26)} ${html.length} octets`);
} catch (err) {
fail(label, err);
}
}
function renderPublic(path: string) {
try {
const client = new QueryClient();
const router = createMemoryRouter(routes, { initialEntries: [path] });
const html = renderToString(
createElement(
QueryClientProvider,
{ client },
createElement(AuthProvider, null, createElement(RouterProvider, { router })),
),
);
console.log(`ok ${path.padEnd(26)} ${html.length} octets`);
} catch (err) {
fail(path, err);
}
}
// --------------------------------------------------------------------------- //
await checkAuth().catch((err) => fail('checkAuth', err));
await checkPublicForm().catch((err) => fail('checkPublicForm', err));
await checkPositionSheets().catch((err) => fail('checkPositionSheets', err));
await checkShiftCreation().catch((err) => fail('checkShiftCreation', err));
await checkGridLayout().catch((err) => fail('checkGridLayout', err));
renderPublic('/connexion');
renderPublic('/connexion/coordination');
renderPublic('/inscription'); // formulaire public, hors de <RequireAuth/>
if (coordinator) {
setAuthToken(coordinatorToken);
await renderProtected('/grille', coordinator, createElement(GrillePage));
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
await renderProtected('/badges', coordinator, createElement(BadgesPage));
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
}
if (volunteerAccount) {
const vid = volunteerAccount.volunteer_id as string;
await renderProtected(
'/mobile-benevole',
volunteerAccount,
createElement(MobileVolunteerPage),
async (client) => {
await client.prefetchQuery({
queryKey: queryKeys.assignments({ volunteerId: vid }),
queryFn: () => api.assignments.list(e, { volunteerId: vid }),
});
await client.prefetchQuery({
queryKey: ['volunteer', e, vid],
queryFn: () => api.volunteers.get(e, vid),
});
},
);
}
if (leadAccount) {
await renderProtected('/mobile-referent', leadAccount, createElement(MobileLeadPage));
}
process.exit(failures ? 1 : 0);