Le panneau créait un créneau, pour un jour. Or un même horaire se répète d'un jour à l'autre bien plus souvent qu'il ne varie : ressaisir cinq fois « 9 h–17 h, pause 30 » était le geste le plus répété de la semaine, et le plus exposé à la faute de frappe. Les sept jours sont désormais cochables. Le jour ouvert est coché et verrouillé — on ne crée pas ailleurs qu'ici depuis la case d'un jour donné — et voyage dans un champ caché, puisqu'une case désactivée n'est pas envoyée. La création est **tout ou rien**. Un jour refusé, pour période close ou chevauchement, annule les autres : une répétition à demi appliquée laisserait un planning que personne n'a voulu, et qu'il faudrait défaire à la main pour le refaire. Le contrôle de période porte donc sur tous les jours demandés avant la première écriture. Trois champs du modèle étaient inatteignables depuis l'écran : le nombre de repas, la note du créneau, et l'étiquette en modification. Cette dernière était un vrai défaut — une étiquette posée de travers se corrigeait en supprimant le créneau pour le refaire, ce qui perdait sa validation et son historique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
258 lines
7.4 KiB
TypeScript
258 lines
7.4 KiB
TypeScript
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,
|
||
mealCount: 0,
|
||
poste: 'vte',
|
||
labelId: null,
|
||
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,
|
||
mealCount: 0,
|
||
poste: 'inv',
|
||
labelId: null,
|
||
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',
|
||
]);
|
||
});
|
||
|
||
it('ne compte au RC que le repos compensateur', () => {
|
||
// Le repos hebdomadaire est un droit déjà pris ; le compensateur est une
|
||
// contrepartie due. Les additionner ferait disparaître la dette dans un
|
||
// total qui ne veut rien dire.
|
||
const { rows } = buildRows([person()], [], DATES, TZ, false, [], [
|
||
{ membershipId: 'm1', restType: 'COMPENSATORY_REST', minutes: 420 },
|
||
{ membershipId: 'm1', restType: 'WEEKLY_REST', minutes: 2100 },
|
||
]);
|
||
|
||
expect(rows[0]?.counters.compensatoryRestMinutes).toBe(420);
|
||
});
|
||
|
||
it('tolère un repos sans durée', () => {
|
||
// `Rest.minutes` est nullable : un repos posé sans durée vaut zéro minute,
|
||
// pas NaN — un NaN se propagerait au bandeau et à l'export.
|
||
const { rows } = buildRows([person()], [], DATES, TZ, false, [], [
|
||
{ membershipId: 'm1', restType: 'COMPENSATORY_REST', minutes: null },
|
||
]);
|
||
|
||
expect(rows[0]?.counters.compensatoryRestMinutes).toBe(0);
|
||
});
|
||
});
|
||
|
||
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',
|
||
);
|
||
});
|
||
});
|