WP-04 (suite) : vues par poste et par mois, duplication, édition, impression

Édition
- Le panneau de créneau sert aussi à la modification : déplacer un créneau,
  c'est changer son jour, son salarié ou ses heures — exactement les champs de
  la création. Deux écrans distincts finiraient par diverger.
- Duplication d'une semaine vers une autre, refusée si la destination contient
  déjà des créneaux. Le report se fait **par heure locale**, pas par décalage de
  sept jours : entre mars et avril, sept jours d'écart ne redonnent pas la même
  heure.

Vues
- `/planning/etiquettes` : la semaine par poste. C'est la question du
  responsable d'ouverture — « la caisse est-elle tenue samedi ? » — illisible
  sur une grille dont les lignes sont des personnes. Les postes sans créneau
  restent affichés : un poste vide toute la semaine est une information.
- `/planning/mois` : les heures par salarié et par jour, avec les journées de
  plus de 10 h signalées. Vue de contrôle, pas d'édition.
- Les deux lisent `Shift` directement. Un test e2e pose un créneau et le
  retrouve identique dans les quatre vues — c'est ce qui empêche l'une d'elles
  de dériver vers son propre calcul.

Impression
- Feuille d'impression A4 paysage : les zones à défilement se déplient, les
  lignes ne se coupent pas entre deux pages, et les aplats de poste sortent
  (`print-color-adjust: exact`) sans être nécessaires à la lecture, puisque le
  code du poste est imprimé dans chaque bloc.
- Pas de moteur PDF côté serveur : le navigateur sait paginer et enregistrer en
  PDF. Un second moteur de rendu serait un second endroit où la grille peut
  diverger de ce qui est à l'écran.

Accessibilité
- La grille porte enfin ses rôles ARIA (`grid`, `row`, `columnheader`,
  `rowheader`, `gridcell`). Elle est faite de div pour la mise en page, mais se
  lit comme un tableau : un lecteur d'écran doit pouvoir annoncer « ligne
  Camille Ferrand, colonne mercredi ».

Cette structure a d'ailleurs révélé un test faux : `locator('div')` remontait au
conteneur de la grille, et le créneau du test atterrissait sur le premier
salarié de l'équipe au lieu de celui visé — une erreur invisible à l'écran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 07:20:36 +00:00
1 parent 023f236881
commit d9f55f866f
15 files changed
+1377 -56

No files matched your search

+49 -4
View File
@@ -13,7 +13,12 @@ import { expect, test, type Locator, type Page } from '@playwright/test';
* ou déjà rempli.
*/
/**
* Chaque test possède **sa** semaine : la suite tourne en parallèle, et deux
* tests qui videraient puis rempliraient la même grille se marcheraient dessus.
*/
const WEEK = '2027-W20';
const CROSS_VIEW_WEEK = '2027-W22';
function venteSection(page: Page): Locator {
return page
@@ -21,8 +26,8 @@ function venteSection(page: Page): Locator {
.filter({ has: page.getByRole('heading', { name: 'Vente' }) });
}
async function resetWeek(page: Page): Promise<void> {
await page.goto(`/planning/semaine?semaine=${WEEK}`);
async function resetWeek(page: Page, week = WEEK): Promise<void> {
await page.goto(`/planning/semaine?semaine=${week}`);
const section = venteSection(page);
await expect(section).toBeVisible();
@@ -49,9 +54,12 @@ async function addShift(
section: Locator,
times: { start: string; end: string; pause?: string },
): Promise<Locator> {
// Viser la ligne par son rôle ARIA : `locator('div')` remonterait au
// conteneur de la grille, et le premier bouton trouvé serait celui d'un
// autre salarié — une erreur qui ne se voit pas à l'écran.
const row = section
.locator('div')
.filter({ hasText: /Camille Ferrand/ })
.getByRole('row')
.filter({ hasText: 'Camille Ferrand' })
.first();
await row.getByRole('button', { name: /\+ Créneau/ }).first().click();
@@ -114,3 +122,40 @@ test('la navigation de semaine change la grille', async ({ page }) => {
page.getByRole('heading', { name: 'Planning · semaine 20' }),
).toBeVisible();
});
/**
* Les vues lisent le même modèle.
*
* C'est le critère d'acceptation de WP-04 : un créneau posé dans la grille
* hebdomadaire doit apparaître **identique** dans les vues jour, poste et
* mois. Chacune interroge `Shift` directement ; ce test est ce qui empêche
* l'une d'elles de dériver vers son propre calcul.
*/
test('un créneau posé se retrouve dans les quatre vues', async ({ page }) => {
await resetWeek(page, CROSS_VIEW_WEEK);
const section = venteSection(page);
await addShift(page, section, { start: '08:30', end: '16:30', pause: '45' });
await expect(section.getByText('08:30–16:30').first()).toBeVisible();
// Lundi de la semaine 22 de 2027.
const monday = '2027-05-31';
await page.goto(`/planning/jour?jour=${monday}`);
// Chercher le nom dans la chronologie, pas dans l'en-tête de l'application,
// qui affiche déjà « Camille Ferrand, Propriétaire ».
await expect(page.getByTitle(/^ENC · 08:30–16:30$|08:30–16:30/).first()).toBeVisible();
await expect(
page.locator('main').getByText('Camille Ferrand').first(),
).toBeVisible();
await page.goto(`/planning/etiquettes?semaine=${CROSS_VIEW_WEEK}`);
await expect(page.getByText('08:30–16:30').first()).toBeVisible();
await page.goto('/planning/mois?mois=2027-05');
const row = page.getByRole('row').filter({ hasText: 'Camille Ferrand' });
// 8 h d'amplitude moins 45 min de pause : 7,3 h dans la case du jour. Les
// deux vues doivent dire la même durée. `first()` parce que l'autre test de
// ce fichier planifie le même salarié ailleurs dans le mois.
await expect(row.getByText('7,3').first()).toBeVisible();
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import {
daysInMonth,
formatMonthParam,
isoDayOfWeek,
monthDates,
monthLabel,
monthOf,
nextMonth,
parseMonthParam,
previousMonth,
} from '@/domain/planning/month';
describe('daysInMonth', () => {
it('suit le calendrier, y compris les années bissextiles', () => {
expect(daysInMonth({ year: 2026, month: 2 })).toBe(28);
expect(daysInMonth({ year: 2028, month: 2 })).toBe(29);
expect(daysInMonth({ year: 2026, month: 4 })).toBe(30);
expect(daysInMonth({ year: 2026, month: 12 })).toBe(31);
});
it('traite 2100 comme non bissextile', () => {
// Divisible par 4 mais pas par 400 : le piège classique d'une table écrite
// à la main.
expect(daysInMonth({ year: 2100, month: 2 })).toBe(28);
expect(daysInMonth({ year: 2000, month: 2 })).toBe(29);
});
});
describe('monthDates', () => {
it('énumère le mois entier', () => {
const dates = monthDates({ year: 2026, month: 2 });
expect(dates).toHaveLength(28);
expect(dates[0]).toBe('2026-02-01');
expect(dates[27]).toBe('2026-02-28');
});
});
describe('navigation', () => {
it('franchit l’année dans les deux sens', () => {
expect(previousMonth({ year: 2026, month: 1 })).toEqual({
year: 2025,
month: 12,
});
expect(nextMonth({ year: 2026, month: 12 })).toEqual({
year: 2027,
month: 1,
});
});
});
describe('isoDayOfWeek', () => {
it('numérote lundi 1 et dimanche 7', () => {
// Le samedi et le dimanche portent un fond distinct dans la vue mois :
// une erreur de numérotation les décalerait d'un jour toute l'année.
expect(isoDayOfWeek('2026-08-10')).toBe(1);
expect(isoDayOfWeek('2026-08-15')).toBe(6);
expect(isoDayOfWeek('2026-08-16')).toBe(7);
});
});
describe('paramètre d’URL', () => {
it('fait un aller-retour', () => {
const month = { year: 2026, month: 8 };
expect(formatMonthParam(month)).toBe('2026-08');
expect(parseMonthParam('2026-08')).toEqual(month);
});
it('rejette ce qui n’est pas un mois', () => {
expect(parseMonthParam('2026-13')).toBeNull();
expect(parseMonthParam('2026-00')).toBeNull();
expect(parseMonthParam('août')).toBeNull();
expect(parseMonthParam(undefined)).toBeNull();
});
});
describe('libellés', () => {
it('nomme le mois en français', () => {
expect(monthLabel({ year: 2026, month: 8 })).toBe('août 2026');
});
it('déduit le mois d’une date', () => {
expect(monthOf(new Date('2026-08-12T00:00:00Z'))).toEqual({
year: 2026,
month: 8,
});
});
});