Le planning quitte le module de démonstration : la grille lit `WeeklySchedule` et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des salariés fictifs disparaissent. Modèle - `TeamMember` : rattachement d'un salarié à une équipe, distinct de `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état de départ de toute semaine en construction. - RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`. Temps - `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO, jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1 de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1. - `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf heures à minuit donnerait 10 h locales. - La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure d'été 167 — vérifié par test. Écritures - Création, modification, suppression de créneau ; publication et dépublication **par équipe**, avec verrou optimiste sur `version` : deux managers sur la même grille est le cas normal, pas l'exception. - Chevauchement refusé en transaction, pas seulement dans le formulaire. - Modifier une semaine publiée exige `planning.edit_published`, capacité que le rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu. - Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace **avant** l'effacement, sinon l'état supprimé serait perdu. Lecture - `planning.view_unpublished` filtre en base : sans cette capacité, les brouillons ne sont pas chargés du tout. Un test vérifie que les horaires n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait. - Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée réelle plutôt que figée à 06 h–21 h. Vérification - 26 tests unitaires sur le repérage des semaines et la mise en grille. - Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ; et ce que voient un salarié et un manager sur la même semaine. - `scripts/dev-db.sh` : la base de développement est éphémère dans cet environnement, la remonter ne doit pas être une redécouverte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
117 lines
4.2 KiB
TypeScript
117 lines
4.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.
|
||
*/
|
||
|
||
const WEEK = '2027-W20';
|
||
|
||
function venteSection(page: Page): Locator {
|
||
return page
|
||
.locator('section')
|
||
.filter({ has: page.getByRole('heading', { name: 'Vente' }) });
|
||
}
|
||
|
||
async function resetWeek(page: Page): 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> {
|
||
const row = section
|
||
.locator('div')
|
||
.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();
|
||
});
|