Files
planflow/tests/unit/rup.test.ts
T
MichaelandClaude Opus 5 481af181fa
CI / verify (pull_request) Canceled after 0s
Fermer les listes du dossier, et déclarer les titres de séjour
Quatre champs se saisissaient en texte libre là où la valeur appartient à
une liste : nationalité, pays de naissance, pays de l'adresse, département.
« Frnace » y entrait aussi bien que « France », et le registre imprimait la
faute.

Les pays sont stockés par leur code ISO 3166-1 et nommés à l'affichage par
`Intl.DisplayNames` : un nom dépend de la langue et des versions d'ICU, un
code ne bouge pas, et deux cent cinquante traductions n'ont pas à être
entretenues à la main. Les valeurs héritées — « France » en toutes lettres
— sont reconnues à la lecture puis normalisées au premier enregistrement,
plutôt que blanchies par un menu qui ne les retrouve pas.

Les départements portent la Corse et l'outre-mer : « 2A » n'est pas un
nombre, et le département de naissance sert à la déclaration sociale, qui
attend le code.

Les téléphones gagnent leur indicatif, choisi à part et recollé en un seul
champ. L'enregistrer séparément obligerait chaque lecture — annuaire,
export, SMS — à le recomposer, et un numéro à moitié lu ne joint personne.
La découpe prend l'indicatif le plus long : « +352 » commence par « +3 ».

Le titre de séjour se déclare enfin. `WorkPermit` existait depuis le début
sans qu'aucun écran ne l'alimente. L'échéance est exigée dès qu'un titre est
posé — employer quelqu'un dont l'autorisation a expiré est un délit, et sans
date rien ne peut le signaler à temps. Décocher retire le titre plutôt que
de le laisser alerter pour quelqu'un qui n'en relève plus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:48:51 +02:00

190 lines
6.3 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 { describe, expect, it } from 'vitest';
import {
RUP_COLUMNS,
missingMentions,
peopleWithGaps,
type RupPerson,
} from '@/domain/legal/rup';
/**
* Registre unique du personnel — mentions obligatoires.
*
* La sanction se compte **par salarié concerné**, pas par registre : c'est ce
* que le décompte doit refléter, et c'est ce qui décide de l'avertissement
* affiché avant l'édition.
*/
const complete: RupPerson = {
lastName: 'Ferrand',
firstName: 'Camille',
sex: 'F',
nationality: 'France',
birthDate: new Date('1990-04-12T00:00:00Z'),
jobTitle: 'Hôte de caisse',
qualification: 'Niveau 3',
contractLabel: 'CDI',
entryDate: new Date('2024-09-01T00:00:00Z'),
exitDate: null,
};
describe('registre unique du personnel', () => {
it('énumère les mentions fixées par le texte', () => {
expect(RUP_COLUMNS.map((column) => column.key)).toEqual([
'lastName',
'firstName',
'sex',
'nationality',
'birthDate',
'jobTitle',
'qualification',
'contractLabel',
'entryDate',
'exitDate',
]);
});
it('répartit la largeur sur la totalité de la page', () => {
const total = RUP_COLUMNS.reduce((sum, column) => sum + column.width, 0);
expect(total).toBeCloseTo(1, 5);
});
it('ne réclame rien quand toutes les mentions sont là', () => {
expect(missingMentions(complete)).toEqual([]);
});
it('tient une date de sortie absente pour une présence, pas pour un oubli', () => {
// Un salarié en poste n'a pas de date de sortie : la réclamer ferait
// apparaître tout l'effectif courant comme incomplet.
expect(missingMentions({ ...complete, exitDate: null })).toEqual([]);
});
it('nomme les mentions manquantes', () => {
expect(
missingMentions({ ...complete, nationality: null, qualification: '' }),
).toEqual(['Nationalité', 'Qualification']);
});
it('compte les personnes concernées, pas les champs vides', () => {
const bancal: RupPerson = {
...complete,
sex: null,
nationality: null,
birthDate: null,
};
// Trois manques sur une seule personne : une contravention, pas trois.
expect(peopleWithGaps([complete, bancal])).toBe(1);
});
});
describe('rendu du registre', () => {
it('produit un PDF paginé qui porte les manques en pied', async () => {
const { renderRegisterPdf } = await import('@/server/employees/rup-pdf');
// Assez de lignes pour dépasser une page : c'est la rupture que le rendu
// rate le plus volontiers.
const people: RupPerson[] = Array.from({ length: 60 }, (_, index) => ({
...complete,
lastName: `Nom manifestement trop long pour la colonne ${index}`,
sex: index % 2 === 0 ? null : 'F',
}));
const pdf = await renderRegisterPdf({
locationId: 'loc',
locationName: 'La Foir’Fouille',
siret: '12345678901234',
people,
incomplete: 30,
});
expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-');
expect(pdf.length).toBeGreaterThan(1000);
});
it('édite un registre vide plutôt que de refuser', async () => {
const { renderRegisterPdf } = await import('@/server/employees/rup-pdf');
// Un établissement qui vient d'ouvrir n'a personne : le registre existe
// quand même, et son absence serait plus embarrassante que sa vacuité.
const pdf = await renderRegisterPdf({
locationId: 'loc',
locationName: 'Nouvel établissement',
siret: null,
people: [],
incomplete: 0,
});
expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-');
});
});
describe('matricule proposé', () => {
it('part à E0001 sur un effectif vide', async () => {
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
expect(nextEmployeeNumber([])).toBe('E0001');
});
it('suit le dernier rang attribué', async () => {
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
expect(nextEmployeeNumber(['E0001', 'E0007', 'E0003'])).toBe('E0008');
});
it('ignore les matricules repris d’un autre outil', async () => {
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
// Un client qui migre apporte ses propres formes : les compter fausserait
// le rang, et refuser l'embauche pour cette raison serait absurde.
expect(nextEmployeeNumber(['SILAE-42', '00012', 'E0002'])).toBe('E0003');
});
});
describe('référentiels géographiques', () => {
it('nomme les pays en français depuis leur code', async () => {
const { countryLabel } = await import('@/domain/hr/geo');
expect(countryLabel('FR')).toBe('France');
expect(countryLabel('DE')).toBe('Allemagne');
});
it('laisse passer une valeur héritée plutôt que de la blanchir', async () => {
const { countryLabel } = await import('@/domain/hr/geo');
// Les dossiers saisis avant le référentiel portent le nom en clair.
expect(countryLabel('Nouvelle-Calédonie')).toBe('Nouvelle-Calédonie');
});
it('ramène un nom hérité à son code', async () => {
const { toCountryCode } = await import('@/domain/hr/geo');
expect(toCountryCode('France')).toBe('FR');
expect(toCountryCode('FR')).toBe('FR');
expect(toCountryCode('Pays imaginaire')).toBe('');
expect(toCountryCode(null)).toBe('');
});
it('porte la Corse et l’outre-mer', async () => {
const { departmentLabel, isKnownDepartment } = await import('@/domain/hr/geo');
expect(departmentLabel('2A')).toBe('2A - Corse-du-Sud');
expect(departmentLabel('974')).toBe('974 - La Réunion');
expect(isKnownDepartment('20')).toBe(false);
});
it('coupe un numéro sur l’indicatif le plus long', async () => {
const { splitDial } = await import('@/domain/hr/geo');
// « +352 » commence par « +3 » : tester dans l'ordre de la table
// donnerait la France pour un numéro luxembourgeois.
expect(splitDial('+352 621 12 34 56')).toEqual({
dial: '+352',
rest: '621 12 34 56',
});
expect(splitDial('+33 6 12 34 56 78')).toEqual({
dial: '+33',
rest: '6 12 34 56 78',
});
});
it('retombe sur la France pour un numéro sans indicatif', async () => {
const { splitDial } = await import('@/domain/hr/geo');
expect(splitDial('06 12 34 56 78')).toEqual({
dial: '+33',
rest: '06 12 34 56 78',
});
});
});