Un créneau portait un total de pause. Deux pauses de vingt minutes et une coupure de deux heures s'écrivaient toutes deux « 120 », et rien ne permettait de les distinguer — ni à l'écran, ni au contrôle. Or elles ne se planifient pas de la même façon : la première satisfait la pause minimale au-delà de six heures, la seconde interroge l'amplitude de la journée. Chaque pause a désormais sa ligne : durée, début facultatif, libellé, et si elle est rémunérée. Le début reste facultatif à dessein — beaucoup de pauses se prennent « quand c'est calme », et imposer une heure inventerait une précision que le planning n'a pas et qu'un contrôle prendrait pour un engagement. Le créneau garde deux totaux dérivés, et la distinction porte de l'argent. `breakMinutes` reste ce qu'il était, la part non rémunérée déduite du temps de travail : la paie, les compteurs et l'export continuent de le lire sans changer d'un caractère. `paidBreakMinutes` s'ajoute à côté, et la règle de pause minimale regarde la somme des deux — sans quoi un créneau dont la pause de vingt minutes est payée passerait pour un créneau sans pause, et l'employeur le plus généreux serait le seul à recevoir une alerte. La migration reprend les créneaux existants : leur total devient une pause unique, non rémunérée. Laisser la table vide aurait fait disparaître de l'écran des pauses qui comptent pourtant toujours dans les heures payées. En modification, les pauses sont remplacées et non fusionnées : le formulaire porte l'état voulu au complet, et rapprocher ligne à ligne ferait survivre une pause qu'on vient de retirer. L'avis d'affectation part enfin, sous sa propre nature d'envoi. Il est différé à la fin de la transaction : un refus sur le dernier jour d'une répétition annule les autres, et personne ne doit être prévenu d'un créneau qui n'existe pas. La case est décochée par défaut, à l'inverse du produit audité — c'est la publication de la semaine qui prévient, et cette case sert au créneau ajouté après coup, le cas où le salarié ne verrait rien sans elle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
7.5 KiB
TypeScript
262 lines
7.5 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,
|
||
paidBreakMinutes: 0,
|
||
breaks: [],
|
||
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,
|
||
paidBreakMinutes: 0,
|
||
breaks: [],
|
||
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',
|
||
);
|
||
});
|
||
});
|