Files
planflow/tests/e2e/planning.spec.ts
T
Claude d9f55f866f 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
2026-08-08 07:20:36 +00:00

162 lines
6.2 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.
import { expect, test, type Locator, type Page } from '@playwright/test';
/**
* Parcours du planning : poser un créneau, publier, dépublier.
*
* Le test travaille sur une semaine **lointaine** plutôt que la semaine
* courante : celle du seed change à chaque exécution, et un test qui dépend de
* la date du jour finit par échouer un lundi de janvier sans que personne ne
* sache pourquoi.
*
* La base de test n'est pas remise à zéro entre deux passages : la semaine est
* donc vidée au début, sans quoi le second passage partirait d'un état publié
* 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
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Vente' }) });
}
async function resetWeek(page: Page, week = WEEK): Promise<void> {
await page.goto(`/planning/semaine?semaine=${week}`);
const section = venteSection(page);
await expect(section).toBeVisible();
const unpublish = section.getByRole('button', { name: 'Dépublier' });
if (await unpublish.isVisible()) {
await unpublish.click();
await expect(section.getByText('Brouillon')).toBeVisible();
}
const remove = section.getByRole('button', { name: 'Supprimer le créneau' });
for (let guard = 0; guard < 30; guard += 1) {
const before = await remove.count();
if (before === 0) break;
await remove.first().click();
// Compter plutôt que regarder un libellé : c'est la disparition du
// créneau qui est attendue, et elle est observable sans ambiguïté.
await expect(remove).toHaveCount(before - 1);
}
await expect(remove).toHaveCount(0);
}
async function addShift(
page: Page,
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
.getByRole('row')
.filter({ hasText: 'Camille Ferrand' })
.first();
await row.getByRole('button', { name: /\+ Créneau/ }).first().click();
const composer = page.locator('form').filter({ hasText: 'Poste' }).first();
await composer.getByLabel('Début').fill(times.start);
await composer.getByLabel('Fin').fill(times.end);
await composer.getByLabel('Pause (min)').fill(times.pause ?? '0');
await composer.getByRole('button', { name: 'Ajouter' }).click();
return composer;
}
test('un manager pose un créneau, publie, puis dépublie', async ({ page }) => {
await resetWeek(page);
const section = venteSection(page);
// Publier une semaine vide n'a pas de sens : rien à annoncer aux salariés.
await section.getByRole('button', { name: 'Publier' }).click();
await expect(section.getByText(/ne contient aucun créneau/)).toBeVisible();
await addShift(page, section, {
start: '08:30',
end: '16:30',
pause: '45',
});
await expect(section.getByText('08:30–16:30').first()).toBeVisible();
// 8 h d'amplitude moins 45 min de pause : le compteur doit dire 7 h 15,
// pas 8 h. C'est la déduction de la pause qui est vérifiée ici.
await expect(section.getByText('7 h 15').first()).toBeVisible();
// Un créneau qui recouvre le premier compterait deux fois en heures. Le
// refus vient du serveur, en transaction, pas du formulaire.
const composer = await addShift(page, section, {
start: '12:00',
end: '20:00',
});
await expect(composer.getByText(/recouvre cette plage/)).toBeVisible();
await composer.getByRole('button', { name: 'Annuler' }).click();
await section.getByRole('button', { name: 'Publier' }).click();
await expect(section.getByText('Publiée')).toBeVisible();
await section.getByRole('button', { name: 'Dépublier' }).click();
await expect(section.getByText('Brouillon')).toBeVisible();
});
test('la navigation de semaine change la grille', async ({ page }) => {
await page.goto(`/planning/semaine?semaine=${WEEK}`);
await expect(
page.getByRole('heading', { name: 'Planning · semaine 20' }),
).toBeVisible();
await page.getByRole('link', { name: 'Semaine suivante →' }).click();
await expect(
page.getByRole('heading', { name: 'Planning · semaine 21' }),
).toBeVisible();
await page.getByRole('link', { name: '← Semaine précédente' }).click();
await expect(
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();
});