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
This commit is contained in:
27 files changed
+3373
-674
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
formatWeekParam,
|
||||
isoWeekOf,
|
||||
previousIsoWeek,
|
||||
} from '../../src/domain/planning/week';
|
||||
|
||||
/**
|
||||
* Ce que chaque rôle voit du planning.
|
||||
*
|
||||
* Ces tests se connectent eux-mêmes : ils ne peuvent pas réutiliser la session
|
||||
* partagée de la direction, d'où le projet « anonyme ».
|
||||
*/
|
||||
|
||||
async function signIn(page: Page, email: string) {
|
||||
await page.goto('/connexion');
|
||||
await page.getByLabel('Adresse électronique').fill(email);
|
||||
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
|
||||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||
// Attendre la sortie de l'écran de connexion : naviguer tout de suite
|
||||
// annulerait la redirection en cours, et le test repartirait déconnecté.
|
||||
await expect(page).not.toHaveURL(/\/connexion/);
|
||||
}
|
||||
|
||||
const current = isoWeekOf(new Date());
|
||||
const CURRENT_WEEK = formatWeekParam(current);
|
||||
const PUBLISHED_WEEK = formatWeekParam(previousIsoWeek(current));
|
||||
|
||||
test('un salarié ne voit pas une semaine non publiée', async ({ page }) => {
|
||||
await signIn(page, 'salarie@example.test');
|
||||
|
||||
// Le seed laisse la semaine courante en brouillon et publie la précédente.
|
||||
await page.goto(`/planning/semaine?semaine=${CURRENT_WEEK}`);
|
||||
await expect(page.getByText(/n’est pas encore publié/).first()).toBeVisible();
|
||||
|
||||
// Le masquage n'est pas cosmétique : les créneaux ne sont pas chargés, donc
|
||||
// absents du HTML. Un simple `display: none` les y laisserait.
|
||||
expect(await page.content()).not.toContain('09:00–17:00');
|
||||
|
||||
// Ni bouton de publication ni ajout de créneau : les capacités manquent.
|
||||
await expect(page.getByRole('button', { name: 'Publier' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /\+ Créneau/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('un salarié voit la semaine publiée', async ({ page }) => {
|
||||
await signIn(page, 'salarie@example.test');
|
||||
await page.goto(`/planning/semaine?semaine=${PUBLISHED_WEEK}`);
|
||||
|
||||
await expect(page.getByText('Publiée').first()).toBeVisible();
|
||||
await expect(page.getByText('09:00–17:00').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('un manager voit le brouillon et peut le publier', async ({ page }) => {
|
||||
await signIn(page, 'manager.nantes@example.test');
|
||||
await page.goto(`/planning/semaine?semaine=${CURRENT_WEEK}`);
|
||||
|
||||
await expect(page.getByText('Brouillon').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Publier' }).first()).toBeVisible();
|
||||
});
|
||||
@@ -45,7 +45,7 @@ test('connexion, navigation, puis déconnexion', async ({ page }) => {
|
||||
|
||||
await page.getByRole('link', { name: 'Plannings' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Planning · semaine 33/ }),
|
||||
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Déconnexion' }).click();
|
||||
|
||||
@@ -6,14 +6,16 @@ test('les six écrans se chargent et affichent leur contenu', async ({ page }) =
|
||||
|
||||
await page.getByRole('link', { name: 'Plannings' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Planning · semaine 33/ }),
|
||||
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
|
||||
).toBeVisible();
|
||||
// La grille doit porter des créneaux, pas seulement son ossature.
|
||||
await expect(page.getByText('Congés payés').first()).toBeVisible();
|
||||
// La grille doit porter des créneaux venus de la base, pas seulement son
|
||||
// ossature : le seed plante deux semaines sur trois équipes.
|
||||
await expect(page.getByRole('heading', { name: 'Vente' })).toBeVisible();
|
||||
await expect(page.getByText('09:00–17:00').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Vue jour' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Planning · mercredi 12 août/ }),
|
||||
page.getByRole('heading', { name: /^Planning · / }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Équipe', exact: true }).click();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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();
|
||||
});
|
||||
@@ -67,10 +67,13 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
|
||||
// L'effectif vient de la base, pas du module de démonstration.
|
||||
await expect(page.getByText('E0001')).toBeVisible();
|
||||
|
||||
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
|
||||
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
|
||||
const matricule = `E9${Date.now() % 100000}`;
|
||||
const nom = `Sanscompte${matricule}`;
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
await form.locator('input[name="firstName"]').fill('Sans');
|
||||
await form.locator('input[name="lastName"]').fill('Compte');
|
||||
await form.locator('input[name="lastName"]').fill(nom);
|
||||
await form.locator('input[name="employeeNumber"]').fill(matricule);
|
||||
await page.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
@@ -87,5 +90,7 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
|
||||
).toBeVisible();
|
||||
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
|
||||
// applicatif doit tout de même figurer nommément au registre du personnel.
|
||||
await expect(page.getByRole('link', { name: /Sans Compte/ })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRows,
|
||||
shiftState,
|
||||
type BoardShiftInput,
|
||||
type RowPerson,
|
||||
} from '@/domain/planning/board';
|
||||
import { weekDates, zonedInstant } from '@/domain/planning/week';
|
||||
|
||||
const TZ = 'Europe/Paris';
|
||||
const WEEK = { isoYear: 2026, isoWeek: 33 };
|
||||
const DATES = weekDates(WEEK);
|
||||
|
||||
const person = (over: Partial<RowPerson> = {}): RowPerson => ({
|
||||
membershipId: 'm1',
|
||||
firstName: 'Sofia',
|
||||
lastName: 'Marchetti',
|
||||
job: 'Vendeuse conseil',
|
||||
forfaitJours: false,
|
||||
contractMinutes: 35 * 60,
|
||||
...over,
|
||||
});
|
||||
|
||||
const shift = (
|
||||
date: string,
|
||||
start: string,
|
||||
end: string,
|
||||
over: Partial<BoardShiftInput> = {},
|
||||
): BoardShiftInput => ({
|
||||
id: `${date}-${start}`,
|
||||
membershipId: 'm1',
|
||||
startAt: zonedInstant(date, start, TZ),
|
||||
endAt: zonedInstant(date, end, TZ),
|
||||
breakMinutes: 0,
|
||||
poste: 'vte',
|
||||
isValidated: false,
|
||||
note: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('buildRows', () => {
|
||||
it('range chaque créneau dans la colonne de son jour local', () => {
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[shift('2026-08-12', '09:00', '17:00')],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(rows[0]?.days[2]).toHaveLength(1);
|
||||
expect(rows[0]?.days[2]?.[0]?.time).toBe('09:00–17:00');
|
||||
expect(rows[0]?.days.flat()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('affiche un salarié sans créneau', () => {
|
||||
// Un planning se construit à partir d'une grille vide : masquer les
|
||||
// salariés non planifiés rendrait l'écran inutilisable le lundi matin.
|
||||
const { rows } = buildRows([person()], [], DATES, TZ, false);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.counters.plannedMinutes).toBe(0);
|
||||
expect(rows[0]?.counters.restDays).toBe(7);
|
||||
});
|
||||
|
||||
it('déduit la pause du temps planifié', () => {
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[shift('2026-08-10', '09:00', '17:00', { breakMinutes: 60 })],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(rows[0]?.counters.plannedMinutes).toBe(7 * 60);
|
||||
});
|
||||
|
||||
it('compte la nuit du changement d’heure à sa vraie durée', () => {
|
||||
// 25 octobre 2026, retour à l'heure d'hiver : 22 h–06 h dure neuf heures.
|
||||
const dates = weekDates({ isoYear: 2026, isoWeek: 43 });
|
||||
const startAt = zonedInstant('2026-10-24', '22:00', TZ);
|
||||
const endAt = zonedInstant('2026-10-25', '06:00', TZ);
|
||||
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[
|
||||
{
|
||||
id: 's',
|
||||
membershipId: 'm1',
|
||||
startAt,
|
||||
endAt,
|
||||
breakMinutes: 0,
|
||||
poste: 'inv',
|
||||
isValidated: false,
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
dates,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(rows[0]?.counters.plannedMinutes).toBe(9 * 60);
|
||||
// Le créneau se range au jour de son **début**, samedi.
|
||||
expect(rows[0]?.days[5]).toHaveLength(1);
|
||||
expect(rows[0]?.days[6]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sépare les besoins non couverts sur leur propre ligne', () => {
|
||||
const { rows, unassignedRow } = buildRows(
|
||||
[person()],
|
||||
[
|
||||
shift('2026-08-15', '10:00', '18:00', {
|
||||
id: 'open',
|
||||
membershipId: null,
|
||||
}),
|
||||
],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(rows[0]?.days.flat()).toHaveLength(0);
|
||||
expect(unassignedRow?.days[5]).toHaveLength(1);
|
||||
expect(unassignedRow?.days[5]?.[0]?.state).toBe('unassigned');
|
||||
// Pas de contrat sur cette ligne : la comparer à 35 h n'aurait aucun sens.
|
||||
expect(unassignedRow?.counters.contractMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it('n’ouvre pas de ligne « non assigné » quand tout est couvert', () => {
|
||||
const { unassignedRow } = buildRows(
|
||||
[person()],
|
||||
[shift('2026-08-10', '09:00', '17:00')],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(unassignedRow).toBeNull();
|
||||
});
|
||||
|
||||
it('compte les dimanches travaillés', () => {
|
||||
// Le dimanche du maire ouvre droit à majoration **et** repos compensateur :
|
||||
// le compteur doit être juste, pas approximatif.
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[
|
||||
shift('2026-08-16', '10:00', '18:00'),
|
||||
shift('2026-08-14', '10:00', '18:00'),
|
||||
],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(rows[0]?.counters.sundaysWorked).toBe(1);
|
||||
expect(rows[0]?.counters.restDays).toBe(5);
|
||||
});
|
||||
|
||||
it('ignore un créneau hors de la semaine affichée', () => {
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[shift('2026-08-24', '09:00', '17:00')],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
// Le taire vaut mieux que de l'écraser sur lundi, où il fausserait le
|
||||
// compteur sans que rien ne le signale.
|
||||
expect(rows[0]?.counters.plannedMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it('neutralise la durée contractuelle au forfait jours', () => {
|
||||
const { rows } = buildRows(
|
||||
[person({ forfaitJours: true, contractMinutes: 35 * 60 })],
|
||||
[],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(rows[0]?.counters.contractMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it('ordonne les créneaux d’une même journée', () => {
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[
|
||||
shift('2026-08-10', '14:00', '19:00'),
|
||||
shift('2026-08-10', '07:00', '12:00'),
|
||||
],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(rows[0]?.days[0]?.map((entry) => entry.time)).toEqual([
|
||||
'07:00–12:00',
|
||||
'14:00–19:00',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shiftState', () => {
|
||||
it('distingue brouillon, publié et validé', () => {
|
||||
expect(shiftState(false, false, 'm1')).toBe('draft');
|
||||
expect(shiftState(false, true, 'm1')).toBe('published');
|
||||
expect(shiftState(true, true, 'm1')).toBe('validated');
|
||||
});
|
||||
|
||||
it('garde le besoin non couvert reconnaissable même publié', () => {
|
||||
expect(shiftState(false, true, null)).toBe('unassigned');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zonedInstant', () => {
|
||||
it('vise la bonne heure locale été comme hiver', () => {
|
||||
expect(zonedInstant('2026-08-10', '09:00', TZ).toISOString()).toBe(
|
||||
'2026-08-10T07:00:00.000Z',
|
||||
);
|
||||
expect(zonedInstant('2026-01-05', '09:00', TZ).toISOString()).toBe(
|
||||
'2026-01-05T08:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('reste juste le jour du changement d’heure', () => {
|
||||
// 29 mars 2026 : minuit est en UTC+1, 09 h en UTC+2. Ajouter neuf heures à
|
||||
// minuit donnerait 10 h locales.
|
||||
expect(zonedInstant('2026-03-29', '09:00', TZ).toISOString()).toBe(
|
||||
'2026-03-29T07:00:00.000Z',
|
||||
);
|
||||
expect(zonedInstant('2026-10-25', '09:00', TZ).toISOString()).toBe(
|
||||
'2026-10-25T08:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user