Files
planflow/tests/e2e/heures.spec.ts
T
Claude 2e9a4c5efe WP-07 : heures prévu/réalisé/payé et périodes de paie verrouillables
Les trois grandeurs
Sans pointeuse, le réalisé est saisi par le manager. La matrice impose de
distinguer trois grandeurs et non deux, avec deux règles qui ne se négocient
pas :
- **Sans heures réelles, le prévu fait foi.** Attendre une saisie qui ne
  viendra pas ne produirait aucune paie. Une saisie partielle — un début sans
  fin — ne suffit pas : elle donnerait une durée fantaisiste.
- **Le paiement ne dépend jamais de la validation.** Une ligne non validée part
  en paie sur la base du réalisé. Bloquer le paiement d'heures accomplies faute
  de validation est précisément ce que la matrice interdit ; l'écran l'énonce
  pour qu'aucun manager ne croie l'inverse.

Toute correction d'heures déjà saisies exige un motif — sans lui, une
correction est indistinguable d'une erreur de saisie. Une première saisie n'en
demande pas : il n'y a rien à corriger, et exiger un motif à chaque ligne le
ferait remplir machinalement.

Le verrou de période
- Verrouiller fige les instantanés **et** ferme le mois : plus aucun créneau,
  aucune absence, aucune heure réelle ne peut être écrit sur ces dates. Le
  contrôle passe avant l'écriture, dans planning, absences et heures.
- Déverrouiller exige une justification. Rouvrir périme les fichiers déjà
  transmis au cabinet, et six mois plus tard personne ne saurait pourquoi.
- La **péremption d'un export est déduite** de `generatedAt < unlockedAt`,
  jamais stockée : la stocker obligerait à réécrire une trace qui doit rester
  append-only, et une trace réécrite ne prouve plus rien.
- Supprimer une période demande de **retaper son libellé** : un « êtes-vous
  sûr » se clique sans lire, et la suppression efface des instantanés sur
  lesquels un export a pu être bâti.

Un seul calcul pour trois écrans
Les instantanés viennent de `buildPayrollPeriod`, la même fonction que le
rapport de paie et l'export. C'est ce qui satisfait le critère croisé : trois
calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin. Un test
d'intégration compare instantané et rapport sur le même jeu de données.

Tests enfin idempotents
La suite e2e échouait au second passage : les absences acceptées d'une
exécution bloquaient les demandes de la suivante. Trois corrections, dans
l'ordre d'importance — chaque test travaille sur **son** salarié (le
chevauchement se juge par personne), chaque test libère ce qu'il a créé, et les
fenêtres de dates sont écartées d'une exécution à l'autre. Trois passages
consécutifs passent désormais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-08 12:25:02 +00:00

117 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from '@playwright/test';
import { formatMonthParam, monthOf, previousMonth } from '../../src/domain/planning/month';
/**
* Heures et périodes de paie.
*
* Ce que ces tests protègent : qu'un mois transmis au cabinet ne se modifie
* plus par inadvertance, et que rouvrir ce mois soit une décision justifiée
* plutôt qu'un bouton.
*/
const MONTH = formatMonthParam(previousMonth(monthOf(new Date())));
/**
* Mois propre à cette exécution.
*
* Une période est unique par (établissement, bornes) et la base n'est pas
* remise à zéro entre deux passages : un mois calculé sur une plage étroite
* finit par retomber sur une période déjà créée, et le test échoue pour une
* raison sans rapport avec ce qu'il vérifie.
*/
const SALT = Math.floor(Date.now() / 1000);
function uniqueMonth(bucket: number): string {
const year = 2100 + ((SALT + bucket * 997) % 400);
const month = ((Math.floor(SALT / 400) + bucket) % 12) + 1;
return `${year}-${String(month).padStart(2, '0')}`;
}
test('le rapport d’heures affiche les trois grandeurs', async ({ page }) => {
await page.goto(`/rapports/heures?mois=${MONTH}`);
await expect(
page.getByRole('heading', { name: /^Heures travaillées · / }),
).toBeVisible();
// Sans saisie, le prévu fait foi — et l'écran le dit plutôt que d'afficher
// un réalisé vide qu'on prendrait pour zéro heure.
await expect(page.getByText(/le prévu fait foi/)).toBeVisible();
await expect(page.getByText('prévu retenu').first()).toBeVisible();
});
test('l’écran dit que la validation ne conditionne pas le paiement', async ({
page,
}) => {
await page.goto(`/rapports/heures?mois=${MONTH}`);
// Bloquer le paiement d'heures accomplies faute de validation est ce que la
// matrice de conformité interdit : l'écran l'énonce pour qu'aucun manager ne
// croie l'inverse.
await expect(
page.getByText(/elle ne les autorise pas à être payées/),
).toBeVisible();
});
test('verrouiller une période ferme le mois aux modifications', async ({
page,
}) => {
await page.goto('/paie/periodes');
await expect(
page.getByRole('heading', { name: 'Périodes de paie' }),
).toBeVisible();
const month = uniqueMonth(0);
const label = `Test ${Date.now()}`;
const create = page.locator('form').filter({ hasText: 'Créer la période' });
await create.getByLabel('Mois').fill(month);
await create.getByLabel('Libellé').fill(label);
await create.getByRole('button', { name: 'Créer la période' }).click();
const card = page.locator('section').filter({ hasText: label }).first();
await expect(card).toBeVisible();
await expect(card.getByText('Ouverte')).toBeVisible();
await card.getByRole('button', { name: 'Verrouiller la période' }).click();
await expect(card.getByText('Verrouillée')).toBeVisible();
// Déverrouiller sans motif est refusé : rouvrir périme les fichiers déjà
// transmis, et six mois plus tard personne ne saurait pourquoi.
const unlock = card.locator('form').filter({ hasText: 'Déverrouiller' });
await expect(unlock.getByPlaceholder('Motif du déverrouillage')).toBeVisible();
await unlock
.getByPlaceholder('Motif du déverrouillage')
.fill('Correction demandée par le cabinet');
await unlock.getByRole('button', { name: 'Déverrouiller' }).click();
await expect(card.getByText('Ouverte')).toBeVisible();
});
test('supprimer une période exige de retaper son libellé', async ({ page }) => {
await page.goto('/paie/periodes');
const month = uniqueMonth(1);
const label = `Suppr ${Date.now()}`;
const create = page.locator('form').filter({ hasText: 'Créer la période' });
await create.getByLabel('Mois').fill(month);
await create.getByLabel('Libellé').fill(label);
await create.getByRole('button', { name: 'Créer la période' }).click();
const card = page.locator('section').filter({ hasText: label }).first();
await expect(card).toBeVisible();
// Un simple « êtes-vous sûr » se clique sans lire : la confirmation demande
// le libellé exact.
const remove = card.locator('form').filter({ hasText: 'Supprimer' });
await remove.getByRole('button', { name: 'Supprimer' }).click();
await expect(remove.getByText(/saisissez le libellé exact/i)).toBeVisible();
await remove.getByPlaceholder(`Saisir « ${label} »`).fill(label);
await remove.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.locator('section').filter({ hasText: label })).toHaveCount(
0,
);
});