Files
planflow/tests/unit/planning-breaks.test.ts
T
MichaelandClaude Opus 5 fab497acdc Détailler les pauses d'un créneau, et prévenir le salarié
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>
2026-08-11 14:32:03 +02:00

109 lines
3.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
BreakError,
breakTotals,
normaliseBreaks,
totalRestMinutes,
type BreakInput,
} from '@/domain/planning/breaks';
const SHIFT = 8 * 60;
const entry = (over: Partial<BreakInput> = {}): Partial<BreakInput> => ({
startMinutes: null,
durationMinutes: 20,
isPaid: false,
label: null,
...over,
});
describe('normaliseBreaks', () => {
it('écarte les lignes vides sans échouer', () => {
// Une ligne ajoutée puis laissée vide n'est pas une saisie : c'est un
// formulaire en cours.
const breaks = normaliseBreaks([entry(), {}], SHIFT);
expect(breaks).toHaveLength(1);
});
it('refuse une durée nulle ou négative', () => {
expect(() => normaliseBreaks([entry({ durationMinutes: 0 })], SHIFT)).toThrow(
BreakError,
);
expect(() =>
normaliseBreaks([entry({ durationMinutes: -10 })], SHIFT),
).toThrow(BreakError);
});
it('refuse une pause qui sort du créneau', () => {
// Laissée passer, elle fausserait l'amplitude de la journée.
expect(() =>
normaliseBreaks(
[entry({ startMinutes: SHIFT - 10, durationMinutes: 30 })],
SHIFT,
),
).toThrow(BreakError);
});
it('refuse deux pauses situées qui se chevauchent', () => {
// Elles compteraient deux fois : le salarié se verrait retirer un temps
// qu'il n'a pris qu'une fois.
expect(() =>
normaliseBreaks(
[
entry({ startMinutes: 120, durationMinutes: 60 }),
entry({ startMinutes: 150, durationMinutes: 30 }),
],
SHIFT,
),
).toThrow(BreakError);
});
it('accepte deux pauses situées qui se touchent', () => {
const breaks = normaliseBreaks(
[
entry({ startMinutes: 120, durationMinutes: 30 }),
entry({ startMinutes: 150, durationMinutes: 30 }),
],
SHIFT,
);
expect(breaks).toHaveLength(2);
});
it('range les pauses situées avant les autres', () => {
const breaks = normaliseBreaks(
[
entry({ startMinutes: null, durationMinutes: 15 }),
entry({ startMinutes: 240, durationMinutes: 30 }),
entry({ startMinutes: 120, durationMinutes: 20 }),
],
SHIFT,
);
expect(breaks.map((row) => row.startMinutes)).toEqual([120, 240, null]);
});
it('refuse plus de six pauses', () => {
expect(() =>
normaliseBreaks(Array.from({ length: 7 }, () => entry()), SHIFT),
).toThrow(BreakError);
});
});
describe('breakTotals', () => {
it('sépare le déduit du rémunéré', () => {
// La distinction porte de l'argent : une pause payée ne se retire pas du
// temps de travail, mais reste du repos.
const totals = breakTotals([
{ startMinutes: null, durationMinutes: 30, isPaid: false, label: null },
{ startMinutes: null, durationMinutes: 20, isPaid: true, label: null },
]);
expect(totals).toEqual({ breakMinutes: 30, paidBreakMinutes: 20 });
expect(totalRestMinutes(totals)).toBe(50);
});
it('compte zéro sans pause', () => {
expect(breakTotals([])).toEqual({ breakMinutes: 0, paidBreakMinutes: 0 });
});
});