Files
planflow/tests/unit/planning-week.test.ts
Claude 023f236881 WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe
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
2026-08-07 23:43:48 +00:00

216 lines
6.7 KiB
TypeScript
Raw Permalink 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 { describe, expect, it } from 'vitest';
import {
dayHeadings,
formatWeekParam,
isoWeekOf,
mondayOfIsoWeek,
nextIsoWeek,
offsetAt,
parseWeekParam,
previousIsoWeek,
weekBounds,
weekDates,
weekLabel,
weeksInIsoYear,
zonedClock,
zonedDate,
zonedMidnight,
} from '@/domain/planning/week';
const utc = (iso: string) => new Date(iso);
describe('isoWeekOf', () => {
it('numérote une semaine ordinaire', () => {
expect(isoWeekOf(utc('2026-08-12T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 33,
});
});
it('rattache le 1ᵉʳ janvier à l’année ISO précédente quand la norme l’exige', () => {
// 1ᵉʳ janvier 2027 est un vendredi : il appartient à la semaine 53 de 2026.
expect(isoWeekOf(utc('2027-01-01T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 53,
});
});
it('rattache fin décembre à l’année ISO suivante quand la norme l’exige', () => {
// 29 décembre 2025 est un lundi : semaine 1 de 2026.
expect(isoWeekOf(utc('2025-12-29T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 1,
});
});
it('reconnaît les années à 53 semaines', () => {
expect(weeksInIsoYear(2026)).toBe(53);
expect(weeksInIsoYear(2025)).toBe(52);
});
});
describe('mondayOfIsoWeek', () => {
it('est l’inverse de isoWeekOf', () => {
for (let week = 1; week <= 52; week += 1) {
const monday = mondayOfIsoWeek({ isoYear: 2026, isoWeek: week });
expect(monday.getUTCDay()).toBe(1);
expect(isoWeekOf(monday)).toEqual({ isoYear: 2026, isoWeek: week });
}
});
it('place la semaine 1 de 2026 au 29 décembre 2025', () => {
expect(
mondayOfIsoWeek({ isoYear: 2026, isoWeek: 1 }).toISOString().slice(0, 10),
).toBe('2025-12-29');
});
});
describe('navigation de semaine', () => {
it('recule sur l’année précédente en respectant sa longueur', () => {
// 2026 compte 53 semaines : reculer depuis la 1 de 2027 doit donner 53,
// pas 52. Une erreur ici fait disparaître une semaine de planning par an.
expect(previousIsoWeek({ isoYear: 2027, isoWeek: 1 })).toEqual({
isoYear: 2026,
isoWeek: 53,
});
expect(previousIsoWeek({ isoYear: 2026, isoWeek: 1 })).toEqual({
isoYear: 2025,
isoWeek: 52,
});
});
it('avance au-delà de la dernière semaine', () => {
expect(nextIsoWeek({ isoYear: 2026, isoWeek: 53 })).toEqual({
isoYear: 2027,
isoWeek: 1,
});
expect(nextIsoWeek({ isoYear: 2025, isoWeek: 52 })).toEqual({
isoYear: 2026,
isoWeek: 1,
});
});
it('fait un aller-retour sans dérive sur toute une année', () => {
let week = { isoYear: 2026, isoWeek: 1 };
for (let step = 0; step < 60; step += 1) week = nextIsoWeek(week);
for (let step = 0; step < 60; step += 1) week = previousIsoWeek(week);
expect(week).toEqual({ isoYear: 2026, isoWeek: 1 });
});
});
describe('weekDates', () => {
it('donne sept jours consécutifs du lundi au dimanche', () => {
expect(weekDates({ isoYear: 2026, isoWeek: 33 })).toEqual([
'2026-08-10',
'2026-08-11',
'2026-08-12',
'2026-08-13',
'2026-08-14',
'2026-08-15',
'2026-08-16',
]);
});
});
describe('fuseau horaire', () => {
it('mesure le décalage de Paris en hiver et en été', () => {
expect(offsetAt(utc('2026-01-15T12:00:00Z'), 'Europe/Paris')).toBe(
3_600_000,
);
expect(offsetAt(utc('2026-07-15T12:00:00Z'), 'Europe/Paris')).toBe(
7_200_000,
);
});
it('place minuit local au bon instant absolu', () => {
expect(zonedMidnight('2026-08-10', 'Europe/Paris').toISOString()).toBe(
'2026-08-09T22:00:00.000Z',
);
expect(zonedMidnight('2026-01-05', 'Europe/Paris').toISOString()).toBe(
'2026-01-04T23:00:00.000Z',
);
});
it('tient au jour même du changement d’heure', () => {
// 25 octobre 2026 : retour à l'heure d'hiver. Minuit local est encore en
// UTC+2 ; une seule passe de correction donnerait une heure de trop.
expect(zonedMidnight('2026-10-25', 'Europe/Paris').toISOString()).toBe(
'2026-10-24T22:00:00.000Z',
);
expect(zonedMidnight('2026-03-29', 'Europe/Paris').toISOString()).toBe(
'2026-03-28T23:00:00.000Z',
);
});
it('mesure une semaine de changement d’heure à sa vraie durée', () => {
// La semaine du passage à l'heure d'hiver dure 169 h, pas 168. Un calcul
// en heures murales la croirait normale et fausserait tout compteur qui
// s'appuierait sur ses bornes.
const autumn = weekBounds({ isoYear: 2026, isoWeek: 43 }, 'Europe/Paris');
expect((autumn.to.getTime() - autumn.from.getTime()) / 3_600_000).toBe(169);
const spring = weekBounds({ isoYear: 2026, isoWeek: 13 }, 'Europe/Paris');
expect((spring.to.getTime() - spring.from.getTime()) / 3_600_000).toBe(167);
});
it('restitue la date et l’heure locales d’un instant', () => {
// 22 h UTC le 9 août, c'est déjà le 10 août à Paris : le créneau de lundi
// matin doit se ranger dans la colonne du lundi.
const instant = utc('2026-08-09T22:00:00Z');
expect(zonedDate(instant, 'Europe/Paris')).toBe('2026-08-10');
expect(zonedClock(instant, 'Europe/Paris')).toBe('00:00');
expect(zonedClock(utc('2026-08-10T07:30:00Z'), 'Europe/Paris')).toBe(
'09:30',
);
});
});
describe('libellés', () => {
it('compose les en-têtes de colonnes', () => {
expect(dayHeadings({ isoYear: 2026, isoWeek: 33 })).toEqual([
'Lun. 10',
'Mar. 11',
'Mer. 12',
'Jeu. 13',
'Ven. 14',
'Sam. 15',
'Dim. 16',
]);
});
it('compose le libellé de semaine', () => {
expect(weekLabel({ isoYear: 2026, isoWeek: 33 })).toBe(
'Semaine 33 · 10 – 16 août 2026',
);
});
it('nomme les deux mois quand la semaine est à cheval', () => {
expect(weekLabel({ isoYear: 2026, isoWeek: 31 })).toBe(
'Semaine 31 · 27 juillet – 2 août 2026',
);
});
});
describe('paramètre d’URL', () => {
it('fait un aller-retour', () => {
const week = { isoYear: 2026, isoWeek: 7 };
expect(parseWeekParam(formatWeekParam(week))).toEqual(week);
expect(formatWeekParam(week)).toBe('2026-W07');
});
it('rejette ce qui n’est pas une semaine', () => {
// Le paramètre vient de l'URL : il est fourni par le visiteur, donc
// toujours suspect. Une semaine 54 ne doit pas atteindre la base.
expect(parseWeekParam('2026-W54')).toBeNull();
expect(parseWeekParam('2026-W00')).toBeNull();
expect(parseWeekParam('août')).toBeNull();
expect(parseWeekParam(undefined)).toBeNull();
});
it('accepte la 53ᵉ semaine d’une année longue', () => {
expect(parseWeekParam('2026-W53')).toEqual({ isoYear: 2026, isoWeek: 53 });
expect(parseWeekParam('2025-W53')).toBeNull();
});
});