Le bandeau de la grille affichait contrat, planifié, écart, dimanches et jours de repos. PLAN.md §7.4 en demande cinq autres : heures contractuelles, planifié, absences, écart, repos compensateur. La capture du produit audité le confirme. L'écart n'était pas cosmétique. Les dimanches travaillés et les jours de repos se lisent déjà sur la grille, colonne par colonne. Le repos compensateur, lui, ne se lit nulle part ailleurs : c'est une contrepartie due — au dimanche travaillé, au dépassement de contingent — et l'omettre du bandeau masquait la seule valeur qui dit qu'une dette existe. Le compteur RC n'était alimenté par rien : les repos posés n'étaient tout simplement pas chargés avec la semaine. Ils le sont, et seul le repos compensateur y entre — le repos hebdomadaire est un droit déjà pris, les additionner ferait disparaître la dette dans un total qui ne veut rien dire. Deux écrans de réglages complètent le lot. L'impression gouverne le planning affiché en salle : orientation, densité, totaux, dimanche, colonne d'émargement. Cette dernière porte un avertissement — un émargement papier ne vaut pas décompte du temps de travail, et le décompte reste celui de PlanFlow. L'objectif de productivité est nullable et non « zéro par défaut » : zéro est un objectif inatteignable, l'absence de valeur dit qu'aucun objectif n'a été fixé. Vider le champ efface donc l'objectif au lieu de le mettre à zéro. L'écran rappelle aussi ce que l'indicateur n'est pas : il éclaire une décision d'organisation, il ne mesure pas le travail d'une personne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
computeWeekCounters,
|
||
deltaTone,
|
||
formatDelta,
|
||
formatMinutes,
|
||
shiftMinutes,
|
||
} from '@/domain/counters/week';
|
||
|
||
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
|
||
|
||
describe('formatMinutes', () => {
|
||
it('formate en heures et minutes sur deux chiffres', () => {
|
||
expect(formatMinutes(h(35))).toBe('35 h 00');
|
||
expect(formatMinutes(h(8, 5))).toBe('8 h 05');
|
||
expect(formatMinutes(0)).toBe('0 h 00');
|
||
});
|
||
|
||
it('utilise le signe moins typographique pour les négatifs', () => {
|
||
expect(formatMinutes(-h(1, 30))).toBe('−1 h 30');
|
||
});
|
||
});
|
||
|
||
describe('formatDelta', () => {
|
||
it('distingue un écart nul d’un manque', () => {
|
||
// « 0 h 00 » seul se lit comme une absence de donnée ; le « + » dit que le
|
||
// contrat est atteint exactement.
|
||
expect(formatDelta(0)).toBe('+0 h 00');
|
||
expect(formatDelta(h(2, 55))).toBe('+2 h 55');
|
||
expect(formatDelta(-h(0, 5))).toBe('−0 h 05');
|
||
});
|
||
});
|
||
|
||
describe('deltaTone', () => {
|
||
it('classe le dépassement, le manque et l’exact', () => {
|
||
expect(deltaTone(0)).toBe('flat');
|
||
expect(deltaTone(1)).toBe('over');
|
||
expect(deltaTone(-1)).toBe('under');
|
||
});
|
||
});
|
||
|
||
describe('computeWeekCounters', () => {
|
||
it('compte les absences dans l’atteinte du contrat', () => {
|
||
// Une semaine entière de congés ne doit pas apparaître en manque de 30 h :
|
||
// le salarié n'a pas sous-travaillé, il était absent.
|
||
const counters = computeWeekCounters({
|
||
contractMinutes: h(30),
|
||
plannedMinutes: h(12),
|
||
absenceMinutes: h(18),
|
||
sundaysWorked: 0,
|
||
restDays: 5,
|
||
compensatoryRestMinutes: 0,
|
||
});
|
||
|
||
expect(counters.deltaMinutes).toBe(0);
|
||
expect(counters.deltaLabel).toBe('+0 h 00');
|
||
expect(counters.tone).toBe('flat');
|
||
});
|
||
|
||
it('signale un dépassement du contrat', () => {
|
||
const counters = computeWeekCounters({
|
||
contractMinutes: h(35),
|
||
plannedMinutes: h(36, 30),
|
||
absenceMinutes: 0,
|
||
sundaysWorked: 2,
|
||
restDays: 2,
|
||
compensatoryRestMinutes: 0,
|
||
});
|
||
|
||
expect(counters.deltaLabel).toBe('+1 h 30');
|
||
expect(counters.tone).toBe('over');
|
||
});
|
||
|
||
it('signale un contrat non atteint', () => {
|
||
const counters = computeWeekCounters({
|
||
contractMinutes: h(35),
|
||
plannedMinutes: h(33, 15),
|
||
absenceMinutes: 0,
|
||
sundaysWorked: 0,
|
||
restDays: 2,
|
||
compensatoryRestMinutes: 0,
|
||
});
|
||
|
||
expect(counters.deltaLabel).toBe('−1 h 45');
|
||
expect(counters.tone).toBe('under');
|
||
});
|
||
});
|
||
|
||
describe('shiftMinutes', () => {
|
||
it('déduit la pause', () => {
|
||
const start = new Date('2026-08-10T09:00:00+02:00');
|
||
const end = new Date('2026-08-10T17:00:00+02:00');
|
||
expect(shiftMinutes(start, end, 30)).toBe(h(7, 30));
|
||
});
|
||
|
||
it('mesure la durée réelle la nuit du passage à l’heure d’hiver', () => {
|
||
// 25 octobre 2026, 03:00 → 02:00 : la nuit dure une heure de plus.
|
||
// Un calcul sur l'heure murale dirait 8 h ; l'instant dit 9 h.
|
||
const start = new Date('2026-10-24T22:00:00+02:00');
|
||
const end = new Date('2026-10-25T06:00:00+01:00');
|
||
expect(shiftMinutes(start, end)).toBe(h(9));
|
||
});
|
||
|
||
it('mesure la durée réelle la nuit du passage à l’heure d’été', () => {
|
||
// 29 mars 2026, 02:00 → 03:00 : la nuit dure une heure de moins.
|
||
const start = new Date('2026-03-28T22:00:00+01:00');
|
||
const end = new Date('2026-03-29T06:00:00+02:00');
|
||
expect(shiftMinutes(start, end)).toBe(h(7));
|
||
});
|
||
|
||
it('ne renvoie jamais de durée négative', () => {
|
||
const start = new Date('2026-08-10T09:00:00+02:00');
|
||
const end = new Date('2026-08-10T09:10:00+02:00');
|
||
expect(shiftMinutes(start, end, 60)).toBe(0);
|
||
});
|
||
});
|