É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
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
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();
|
||
});
|