Files
gestion_projets/tests/model.test.js
Bertrand Benjamin 815e144d9a
All checks were successful
Build / build (push) Successful in 1m2s
Rendre lisibles le statut et la date imposée
Statut : quatre boutons peints de leur texture, si bien que le panneau
sert enfin de légende. « En cours » se raye et « terminé » redevient vif
— la trame les distingue, la teinte n'a plus à le faire.

Date imposée : un cadre tireté cerne la forme entière. Les taquets
tombaient sous les poignées de redimensionnement et disparaissaient sur
une barre courte. Son libellé passe dans le modèle, ce qui la fait
apparaître dans la vue par mois, qui la taisait.

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

1380 lines
52 KiB
JavaScript
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 { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
ErreurValidation,
MAX_LONGUEUR_TAG,
VERSION_FORMAT,
ajouterJours,
ajouterMois,
ajouterPhase,
bornesHorizon,
bornesPlanning,
bornesProjet,
bornesReference,
ancreDecalage,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
dateValide,
decalerHorizon,
decalerProjet,
definirHorizon,
deplacerPhase,
dernierJourDuMois,
dimancheDeLaSemaine,
deriveProjet,
duree,
ecarterProjet,
ecartJours,
ecartMois,
empriseProjet,
entreesParMois,
etatProjet,
fabriquerId,
figerReference,
formaterDateLongue,
formaterHorizon,
formaterMois,
formaterPeriode,
formulerDerive,
libelleStatut,
libererReference,
lundiDeLaSemaine,
modifierPhase,
moisValide,
normaliserTags,
phasesDecalables,
planningVide,
pourEcriture,
premierJourDuMois,
projetADater,
projetFiltre,
projetFiltreEtat,
reanimerProjet,
redimensionnerPhase,
remplacerPhase,
rouvrirProjet,
semaineISO,
supprimerPhase,
teinteTag,
terminerProjet,
tousLesTags,
validerPlanning,
} from '../js/model.js';
// --- helpers de fabrication -------------------------------------------------
function phase(champs = {}) {
return {
id: 'cadrage',
name: 'Cadrage',
start: '2026-08-01',
end: '2026-08-20',
status: 'todo',
milestone: false,
notes: '',
...champs,
};
}
function projet(champs = {}) {
return {
id: 'site-web',
name: 'Site web',
color: '#3b82f6',
tags: [],
collapsed: false,
hidden: false,
phases: [],
...champs,
};
}
// --- dates ------------------------------------------------------------------
describe('dates', () => {
test('accepte une date réelle et rejette les formes invalides', () => {
assert.ok(dateValide('2026-08-01'));
assert.ok(dateValide('2024-02-29'), 'année bissextile');
assert.ok(!dateValide('2026-02-30'), 'jour inexistant');
assert.ok(!dateValide('2025-02-29'), 'année non bissextile');
assert.ok(!dateValide('2026-13-01'), 'mois inexistant');
assert.ok(!dateValide('2026-8-1'), 'sans zéro de remplissage');
assert.ok(!dateValide('01/08/2026'));
assert.ok(!dateValide(''));
assert.ok(!dateValide(null));
assert.ok(!dateValide(20260801));
});
test('ajouterJours traverse les mois, les années et les bissextiles', () => {
assert.equal(ajouterJours('2026-08-01', 30), '2026-08-31');
assert.equal(ajouterJours('2026-12-31', 1), '2027-01-01');
assert.equal(ajouterJours('2026-01-01', -1), '2025-12-31');
assert.equal(ajouterJours('2024-02-28', 1), '2024-02-29');
assert.equal(ajouterJours('2025-02-28', 1), '2025-03-01');
assert.equal(ajouterJours('2026-08-01', 0), '2026-08-01');
});
test("ecartJours est signé et vaut zéro entre deux dates égales", () => {
assert.equal(ecartJours('2026-08-01', '2026-08-20'), 19);
assert.equal(ecartJours('2026-08-20', '2026-08-01'), -19);
assert.equal(ecartJours('2026-08-01', '2026-08-01'), 0);
});
test('la durée inclut la date de fin', () => {
assert.equal(duree(phase({ start: '2026-08-01', end: '2026-08-01' })), 1);
assert.equal(duree(phase({ start: '2026-08-01', end: '2026-08-20' })), 20);
});
test('semaineISO suit la règle du jeudi, y compris aux changements d année', () => {
// 2026-01-01 est un jeudi : il ouvre donc la semaine 1 de 2026.
assert.deepEqual(semaineISO('2026-01-01'), { annee: 2026, numero: 1 });
// Le lundi qui précède appartient à la même semaine ISO, en 2026.
assert.deepEqual(semaineISO('2025-12-29'), { annee: 2026, numero: 1 });
// 2027-01-01 est un vendredi : sa semaine contient le jeudi 31/12/2026,
// elle appartient donc encore à 2026.
assert.deepEqual(semaineISO('2027-01-01'), { annee: 2026, numero: 53 });
assert.deepEqual(semaineISO('2026-08-03'), { annee: 2026, numero: 32 });
assert.deepEqual(semaineISO('2026-08-09'), { annee: 2026, numero: 32 }, 'le dimanche clôt la semaine');
assert.deepEqual(semaineISO('2026-08-10'), { annee: 2026, numero: 33 });
});
test('dimancheDeLaSemaine clôt la semaine du lundi correspondant', () => {
assert.equal(dimancheDeLaSemaine('2026-08-05'), '2026-08-09');
assert.equal(dimancheDeLaSemaine('2026-08-03'), '2026-08-09', 'depuis le lundi');
assert.equal(dimancheDeLaSemaine('2026-08-09'), '2026-08-09', 'depuis le dimanche');
});
test('lundiDeLaSemaine recule jusqu au lundi, dimanche compris', () => {
// 2026-08-05 est un mercredi, 2026-08-03 le lundi de sa semaine.
assert.equal(lundiDeLaSemaine('2026-08-05'), '2026-08-03');
assert.equal(lundiDeLaSemaine('2026-08-03'), '2026-08-03', 'un lundi ne bouge pas');
// 2026-08-09 est un dimanche : il appartient à la semaine du 3, pas du 10.
assert.equal(lundiDeLaSemaine('2026-08-09'), '2026-08-03');
});
test('les calculs de dates ne dérivent pas selon le fuseau horaire', () => {
// Ce test attrape la confusion classique entre new Date("...") en UTC et
// new Date(a, m, j) en heure locale, qui décale d'un jour.
for (const tz of ['UTC', 'Pacific/Kiritimati', 'Pacific/Niue']) {
process.env.TZ = tz;
assert.equal(ajouterJours('2026-08-01', 1), '2026-08-02', tz);
assert.equal(lundiDeLaSemaine('2026-08-05'), '2026-08-03', tz);
assert.equal(ecartJours('2026-08-01', '2026-08-02'), 1, tz);
}
delete process.env.TZ;
});
test('formaterDateLongue et formaterMois rendent les dates lisibles', () => {
assert.equal(formaterDateLongue('2026-09-12'), '12 sept. 2026');
assert.equal(formaterMois('2026-09'), 'septembre 2026');
assert.equal(formaterMois('2027-01'), 'janvier 2027');
});
test('formaterPeriode ne nomme le mois que lorsque la phase en déborde', () => {
const p = (start, end, milestone = false) => ({ start, end, milestone });
assert.equal(formaterPeriode(p('2026-09-12', '2026-09-12', true)), '12');
assert.equal(formaterPeriode(p('2026-09-01', '2026-09-30')), '1 → 30');
assert.equal(formaterPeriode(p('2026-09-15', '2026-11-04')), '15 → 4 nov.');
// Franchir l'année ajoute le millésime, sans quoi « 15 → 4 janv. » se
// lirait comme un retour en arrière de huit mois.
assert.equal(formaterPeriode(p('2026-09-15', '2027-01-04')), '15 → 4 janv. 2027');
});
});
// --- mois -------------------------------------------------------------------
describe('mois', () => {
test('moisValide accepte « AAAA-MM » et rejette le reste', () => {
assert.ok(moisValide('2027-03'));
assert.ok(moisValide('2027-12'));
assert.ok(!moisValide('2027-13'), 'mois inexistant');
assert.ok(!moisValide('2027-00'), 'mois zéro');
assert.ok(!moisValide('2027-3'), 'sans zéro de remplissage');
assert.ok(!moisValide('2027-03-01'), 'une date n est pas un mois');
assert.ok(!moisValide(''));
assert.ok(!moisValide(null));
assert.ok(!moisValide(202703));
});
test('les bornes d un mois encadrent ses jours, bissextiles comprises', () => {
assert.equal(premierJourDuMois('2027-03'), '2027-03-01');
assert.equal(dernierJourDuMois('2027-03'), '2027-03-31');
assert.equal(dernierJourDuMois('2027-04'), '2027-04-30');
assert.equal(dernierJourDuMois('2028-02'), '2028-02-29', 'année bissextile');
assert.equal(dernierJourDuMois('2027-02'), '2027-02-28');
assert.equal(dernierJourDuMois('2026-12'), '2026-12-31', 'report d année');
});
test('ajouterMois traverse les années dans les deux sens', () => {
assert.equal(ajouterMois('2026-11', 3), '2027-02');
assert.equal(ajouterMois('2026-02', -3), '2025-11');
assert.equal(ajouterMois('2026-12', 1), '2027-01');
assert.equal(ajouterMois('2026-01', -1), '2025-12');
assert.equal(ajouterMois('2026-08', 0), '2026-08');
assert.equal(ajouterMois('2026-08', 24), '2028-08');
});
test('ecartMois est signé et vaut zéro entre deux mois égaux', () => {
assert.equal(ecartMois('2026-11', '2027-02'), 3);
assert.equal(ecartMois('2027-02', '2026-11'), -3);
assert.equal(ecartMois('2026-08', '2026-08'), 0);
});
test('formaterHorizon n écrit l année qu une fois quand elle est partagée', () => {
assert.equal(formaterHorizon({ start: '2027-03', end: '2027-03' }), 'mars 2027');
assert.equal(formaterHorizon({ start: '2027-03', end: '2027-06' }), 'mars → juin 2027');
assert.equal(
formaterHorizon({ start: '2026-11', end: '2027-02' }),
'novembre 2026 → février 2027'
);
assert.equal(formaterHorizon(undefined), '');
});
});
// --- identifiants -----------------------------------------------------------
describe('fabriquerId', () => {
test('translittère accents, majuscules et ponctuation', () => {
assert.equal(fabriquerId('Refonte du site web'), 'refonte-du-site-web');
assert.equal(fabriquerId('Étude préalable'), 'etude-prealable');
assert.equal(fabriquerId('Phase 1 : cadrage !'), 'phase-1-cadrage');
assert.equal(fabriquerId(' espaces '), 'espaces');
});
test('se rabat sur un identifiant lisible quand rien ne subsiste', () => {
assert.equal(fabriquerId('!!!'), 'sans-nom');
assert.equal(fabriquerId(''), 'sans-nom');
});
test('suffixe en cas de collision, sans jamais réutiliser un identifiant pris', () => {
assert.equal(fabriquerId('Cadrage', []), 'cadrage');
assert.equal(fabriquerId('Cadrage', ['cadrage']), 'cadrage-2');
assert.equal(fabriquerId('Cadrage', ['cadrage', 'cadrage-2']), 'cadrage-3');
// Le trou dans la suite ne doit pas produire un doublon.
assert.equal(fabriquerId('Cadrage', ['cadrage', 'cadrage-3']), 'cadrage-2');
});
});
// --- validation -------------------------------------------------------------
describe('validerPlanning', () => {
test('accepte un planning correct et le normalise', () => {
const resultat = validerPlanning({
version: 2,
projects: [projet({ phases: [phase()] })],
});
// Un fichier plus ancien est relu et remonté à la version courante : c'est
// ce qui permet à un planning écrit avant les tags de s'ouvrir sans rien
// demander, ses projets recevant simplement une liste de tags vide.
assert.equal(resultat.version, VERSION_FORMAT);
assert.deepEqual(resultat.projects[0].tags, []);
assert.equal(resultat.projects.length, 1);
assert.equal(resultat.projects[0].phases[0].id, 'cadrage');
});
test('accepte un planning vide', () => {
assert.deepEqual(validerPlanning({ version: 2, projects: [] }), planningVide());
});
test('trie les phases par date de début', () => {
const resultat = validerPlanning({
version: 2,
projects: [
projet({
phases: [
phase({ id: 'dev', name: 'Dev', start: '2026-10-01', end: '2026-11-30' }),
phase({ id: 'cadrage', name: 'Cadrage', start: '2026-08-01', end: '2026-08-20' }),
],
}),
],
});
assert.deepEqual(
resultat.projects[0].phases.map((p) => p.id),
['cadrage', 'dev']
);
});
test('rejette une fin antérieure au début, en nommant le fautif', () => {
assert.throws(
() =>
validerPlanning({
projects: [projet({ phases: [phase({ start: '2026-08-20', end: '2026-08-01' })] })],
}),
(err) => err instanceof ErreurValidation && /Cadrage/.test(err.message)
);
});
test('rejette un jalon qui ne tient pas sur un seul jour', () => {
assert.throws(
() =>
validerPlanning({
projects: [
projet({
phases: [phase({ milestone: true, start: '2026-08-01', end: '2026-08-20' })],
}),
],
}),
ErreurValidation
);
});
test('accepte un jalon correct', () => {
const resultat = validerPlanning({
projects: [
projet({ phases: [phase({ milestone: true, start: '2026-11-02', end: '2026-11-02' })] }),
],
});
assert.equal(resultat.projects[0].phases[0].milestone, true);
});
test('rejette les identifiants en double', () => {
assert.throws(
() => validerPlanning({ projects: [projet(), projet()] }),
(err) => /identifiant/.test(err.message)
);
assert.throws(
() =>
validerPlanning({
projects: [projet({ phases: [phase(), phase({ name: 'Autre' })] })],
}),
(err) => /identifiant/.test(err.message)
);
});
test('rejette une structure de fichier absente ou aberrante', () => {
assert.throws(() => validerPlanning(null), ErreurValidation);
assert.throws(() => validerPlanning({}), ErreurValidation);
assert.throws(() => validerPlanning({ projects: 'oui' }), ErreurValidation);
assert.throws(() => validerPlanning({ projects: [projet({ phases: null })] }), ErreurValidation);
});
test('refuse un fichier écrit par une version plus récente', () => {
assert.throws(
() => validerPlanning({ version: 99, projects: [] }),
(err) => /version 99/.test(err.message)
);
});
test('donne des notes vides à un projet qui n en a pas', () => {
// Un fichier en version 3 n'en portait pas : il s'ouvre sans rien demander,
// comme un fichier d'avant les tags s'ouvrait avec une liste de tags vide.
const resultat = validerPlanning({ version: 3, projects: [projet()] });
assert.equal(resultat.projects[0].notes, '');
});
test('une phase d un fichier en version 5 n a pas de date imposée', () => {
// Rien dans les données ne permettrait de deviner laquelle l'était : la
// migration ne devine pas, elle met tout à `false`.
const resultat = validerPlanning({ version: 5, projects: [projet({ phases: [phase()] })] });
assert.equal(resultat.projects[0].phases[0].fixed, false);
});
test('conserve une date imposée, et l écrit dans le fichier', () => {
const resultat = validerPlanning({
projects: [projet({ phases: [phase({ fixed: true })] })],
});
assert.equal(resultat.projects[0].phases[0].fixed, true);
const relu = JSON.parse(JSON.stringify(pourEcriture(resultat)));
assert.equal(relu.version, VERSION_FORMAT);
assert.equal(relu.projects[0].phases[0].fixed, true);
});
test('conserve les notes d un projet', () => {
const resultat = validerPlanning({
projects: [projet({ notes: '## Contexte\n\n- [ ] relancer' })],
});
assert.equal(resultat.projects[0].notes, '## Contexte\n\n- [ ] relancer');
});
test('ramène à vide des notes de projet mal typées', () => {
// Cosmétique comme le statut d'une phase : le champ n'engage aucun calcul,
// et bloquer le chargement d'un planning entier pour lui serait
// disproportionné.
const resultat = validerPlanning({ projects: [projet({ notes: 42 })] });
assert.equal(resultat.projects[0].notes, '');
});
test('ramène un statut inconnu à « à venir » sans bloquer le chargement', () => {
const resultat = validerPlanning({
projects: [projet({ phases: [phase({ status: 'n-importe-quoi' })] })],
});
assert.equal(resultat.projects[0].phases[0].status, 'todo');
});
test('remplace une couleur invalide par la couleur par défaut', () => {
const resultat = validerPlanning({ projects: [projet({ color: 'bleu' })] });
assert.equal(resultat.projects[0].color, '#3b82f6');
});
test('rejette une référence dont les dates sont invalides', () => {
assert.throws(
() =>
validerPlanning({
projects: [
projet({ phases: [phase({ baseline: { start: '2026-13-01', end: '2026-08-10' } })] }),
],
}),
ErreurValidation
);
});
});
// --- tags -------------------------------------------------------------------
describe('tags', () => {
test('normaliserTags nettoie, dédoublonne et trie', () => {
assert.deepEqual(
normaliserTags([' web ', 'client', '', ' ', 'Client', 'a\t b']),
['a b', 'client', 'web']
);
});
test('normaliserTags garde la première graphie rencontrée', () => {
assert.deepEqual(normaliserTags(['Client', 'client', 'CLIENT']), ['Client']);
});
test('normaliserTags tronque un tag trop bavard plutôt que de le refuser', () => {
const [tag] = normaliserTags(['x'.repeat(MAX_LONGUEUR_TAG + 10)]);
assert.equal(tag.length, MAX_LONGUEUR_TAG);
});
test('cleTag ignore la casse et les espaces, mais pas les accents', () => {
assert.equal(cleTag(' Sous-Traitance '), cleTag('sous-traitance'));
assert.notEqual(cleTag('éditeur'), cleTag('editeur'));
});
test('teinteTag est déterministe et reste dans la roue chromatique', () => {
assert.equal(teinteTag('client'), teinteTag('Client'));
for (const tag of ['client', 'web', 'interne', 'R&D', 'éditeur']) {
const teinte = teinteTag(tag);
assert.ok(teinte >= 0 && teinte < 360, `${tag}${teinte}`);
}
});
test('tousLesTags rassemble ceux du planning, sans doublon', () => {
const planning = {
projects: [
projet({ id: 'a', tags: ['web', 'client'] }),
projet({ id: 'b', tags: ['Client', 'interne'] }),
projet({ id: 'c' }),
],
};
assert.deepEqual(tousLesTags(planning), ['client', 'interne', 'web']);
});
test('projetFiltre exige tous les tags cochés, pas seulement un', () => {
const p = projet({ tags: ['client', 'urgent', 'web'] });
assert.equal(projetFiltre(p, []), true);
assert.equal(projetFiltre(p, ['client']), true);
assert.equal(projetFiltre(p, ['client', 'urgent']), true);
assert.equal(projetFiltre(p, ['client', 'interne']), false);
assert.equal(projetFiltre(projet({ tags: [] }), ['client']), false);
});
test('projetFiltre compare à la casse près', () => {
assert.equal(projetFiltre(projet({ tags: ['Client'] }), ['client']), true);
});
test('validerPlanning normalise les tags et rejette ceux qui ne sont pas des chaînes', () => {
const resultat = validerPlanning({
projects: [projet({ tags: ['web ', 'Client', 'client'] })],
});
assert.deepEqual(resultat.projects[0].tags, ['Client', 'web']);
assert.throws(
() => validerPlanning({ projects: [projet({ tags: 'client' })] }),
(err) => err instanceof ErreurValidation && /tags/.test(err.message)
);
assert.throws(
() => validerPlanning({ projects: [projet({ tags: [42] })] }),
(err) => err instanceof ErreurValidation && /Site web/.test(err.message)
);
});
test('creerProjet accepte des tags et les normalise', () => {
const p = creerProjet(planningVide(), 'Site web', [' web', 'Client', 'client']);
assert.deepEqual(p.tags, ['Client', 'web']);
});
});
// --- bornes -----------------------------------------------------------------
describe('bornes', () => {
const troisPhases = projet({
phases: [
phase({ id: 'a', name: 'A', start: '2026-08-01', end: '2026-08-20' }),
phase({ id: 'b', name: 'B', start: '2026-09-01', end: '2026-12-15' }),
phase({ id: 'c', name: 'C', start: '2026-08-15', end: '2026-09-30' }),
],
});
test('couvrent de la première à la dernière phase, quel que soit leur ordre', () => {
assert.deepEqual(bornesProjet(troisPhases), { start: '2026-08-01', end: '2026-12-15' });
});
test('valent null pour un projet sans phase', () => {
assert.equal(bornesProjet(projet()), null);
});
test('le planning agrège les projets visibles et ignore les masqués', () => {
const planning = {
version: 2,
projects: [
projet({ id: 'a', phases: [phase({ start: '2026-08-01', end: '2026-08-20' })] }),
projet({ id: 'b', phases: [phase({ start: '2026-01-01', end: '2026-02-01' })] }),
],
};
assert.deepEqual(bornesPlanning(planning), { start: '2026-01-01', end: '2026-08-20' });
planning.projects[1].hidden = true;
assert.deepEqual(bornesPlanning(planning), { start: '2026-08-01', end: '2026-08-20' });
});
test('le planning vaut null quand rien n est visible', () => {
assert.equal(bornesPlanning(planningVide()), null);
assert.equal(bornesPlanning({ projects: [projet({ hidden: true })] }), null);
});
});
// --- regroupement par mois --------------------------------------------------
describe('entreesParMois', () => {
const planning = {
version: VERSION_FORMAT,
projects: [
projet({
id: 'site-web',
name: 'Site web',
tags: ['client'],
phases: [
phase({ id: 'cadrage', name: 'Cadrage', start: '2026-09-01', end: '2026-09-20' }),
phase({
id: 'livraison',
name: 'Livraison',
start: '2026-11-12',
end: '2026-11-12',
milestone: true,
}),
],
}),
projet({
id: 'portail',
name: 'Portail',
tags: ['interne'],
phases: [
// Court sur trois mois : ne doit apparaître qu'en septembre.
phase({ id: 'redaction', name: 'Rédaction', start: '2026-09-15', end: '2026-11-04' }),
],
}),
],
};
const cles = (groupes) => groupes.map((groupe) => groupe.mois);
const noms = (groupe) => groupe.entrees.map(({ phase: p }) => p.name);
test('rattache chaque phase au seul mois de son début', () => {
const groupes = entreesParMois(planning);
assert.deepEqual(cles(groupes), ['2026-09', '2026-11']);
assert.deepEqual(noms(groupes[0]), ['Cadrage', 'Rédaction']);
// « Rédaction » traverse octobre et novembre sans y réapparaître.
assert.deepEqual(noms(groupes[1]), ['Livraison']);
});
test('ne fabrique pas de mois vide entre deux mois pleins', () => {
assert.equal(entreesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
assert.equal(cles(entreesParMois(planning)).includes('2026-10'), false);
});
test('trie les mois du plus ancien au plus récent, à cheval sur les années', () => {
const surDeuxAns = {
projects: [
projet({
phases: [
phase({ id: 'b', start: '2027-01-05', end: '2027-01-10' }),
phase({ id: 'a', start: '2026-12-05', end: '2026-12-10' }),
],
}),
],
};
assert.deepEqual(cles(entreesParMois(surDeuxAns)), ['2026-12', '2027-01']);
});
test('trie un mois par date, puis par projet, puis par nom de phase', () => {
const memeJour = {
projects: [
projet({
id: 'zeta',
name: 'Zeta',
phases: [phase({ id: 'x', name: 'Atelier', start: '2026-09-10', end: '2026-09-11' })],
}),
projet({
id: 'alpha',
name: 'Alpha',
phases: [
phase({ id: 'y', name: 'Bilan', start: '2026-09-10', end: '2026-09-11' }),
phase({ id: 'z', name: 'Amorçage', start: '2026-09-02', end: '2026-09-03' }),
],
}),
],
};
assert.deepEqual(noms(entreesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']);
});
test('applique le filtre par tags, et écarte les projets masqués', () => {
assert.deepEqual(cles(entreesParMois(planning, ['interne'])), ['2026-09']);
assert.deepEqual(noms(entreesParMois(planning, ['interne'])[0]), ['Rédaction']);
const masque = {
projects: planning.projects.map((p) =>
p.id === 'portail' ? { ...p, hidden: true } : p
),
};
assert.deepEqual(noms(entreesParMois(masque)[0]), ['Cadrage']);
});
test('rend une liste vide plutôt que null quand rien ne reste', () => {
assert.deepEqual(entreesParMois(planningVide()), []);
assert.deepEqual(entreesParMois(planning, ['inconnu']), []);
});
test('un projet envisagé paraît au premier mois de son horizon', () => {
// Il n'a aucune phase : sans cette entrée, un projet visé pour mars 2027
// n'apparaîtrait nulle part dans une liste qui répond pourtant à « qu'est-ce
// qui arrive, et quand ».
const avecEnvisage = {
projects: [projet({ id: 'idee', name: 'Idée', horizon: { start: '2027-03', end: '2027-06' } })],
};
const groupes = entreesParMois(avecEnvisage);
assert.deepEqual(cles(groupes), ['2027-03'], "au début de l'horizon, pas à sa fin");
assert.deepEqual(groupes[0].entrees[0].horizon, { start: '2027-03', end: '2027-06' });
assert.equal(groupes[0].entrees[0].phase, undefined);
});
test('un projet envisagé sans horizon ne paraît nulle part', () => {
// Il n'a rien à quoi se rattacher, et sa pastille « à dater » le signale
// déjà dans la colonne de la frise.
assert.deepEqual(entreesParMois({ projects: [projet({ id: 'flottant' })] }), []);
});
test('un projet écarté avant d avoir été engagé paraît à son horizon', () => {
// C'est l'absence de phases qui compte, pas l'état : sans cela, ouvrir le
// cimetière dans la liste ne montrerait que les projets qui avaient démarré.
const cimetiere = {
projects: [
projet({
id: 'abandonne',
name: 'Abandonné',
horizon: { start: '2027-03', end: '2027-03' },
discardedDate: '2026-09-14',
}),
],
};
const groupes = entreesParMois(cimetiere, [], ['discarded']);
assert.deepEqual(cles(groupes), ['2027-03']);
assert.equal(etatProjet(groupes[0].entrees[0].projet), 'discarded');
// Et il reste hors de vue tant qu'on n'a pas coché son état.
assert.deepEqual(entreesParMois(cimetiere, [], ['considered', 'engaged']), []);
});
test('un projet engagé ne paraît pas deux fois malgré son horizon conservé', () => {
// L'horizon survit à l'engagement, mais ce sont les phases qui parlent
// désormais : le lister en plus ferait un doublon.
const engage = {
projects: [
projet({
id: 'engage',
horizon: { start: '2027-03', end: '2027-06' },
phases: [phase({ start: '2026-08-01', end: '2026-08-20' })],
}),
],
};
const groupes = entreesParMois(engage);
assert.deepEqual(cles(groupes), ['2026-08']);
assert.equal(groupes[0].entrees.length, 1);
});
test('un envisagé ouvre son mois, devant les phases qui y démarrent plus tard', () => {
const melange = {
projects: [
projet({ id: 'idee', name: 'Idée', horizon: { start: '2026-09', end: '2026-09' } }),
projet({
id: 'autre',
name: 'Autre',
phases: [phase({ id: 'p', name: 'Tâche', start: '2026-09-15', end: '2026-09-20' })],
}),
],
};
assert.deepEqual(
entreesParMois(melange)[0].entrees.map((e) => e.projet.id),
['idee', 'autre']
);
});
});
// --- référence et dérive ----------------------------------------------------
describe('référence et dérive', () => {
const base = projet({
phases: [
phase({ id: 'cadrage', name: 'Cadrage', start: '2026-08-01', end: '2026-08-20' }),
phase({ id: 'dev', name: 'Dev', start: '2026-09-01', end: '2026-10-30' }),
],
});
test('figer copie les dates courantes et horodate', () => {
const fige = figerReference(base, '2026-06-12');
assert.equal(fige.baselineDate, '2026-06-12');
assert.deepEqual(fige.phases[0].baseline, { start: '2026-08-01', end: '2026-08-20' });
assert.deepEqual(fige.phases[1].baseline, { start: '2026-09-01', end: '2026-10-30' });
});
test('la dérive est nulle juste après le figeage', () => {
assert.equal(deriveProjet(figerReference(base, '2026-06-12')), 0);
});
test('la dérive vaut null tant que rien n est figé', () => {
assert.equal(deriveProjet(base), null);
});
test('décaler la dernière phase produit une dérive positive', () => {
const fige = figerReference(base, '2026-06-12');
const glisse = remplacerPhase(fige, 'dev', (p) => deplacerPhase(p, '2026-09-22'));
assert.equal(deriveProjet(glisse), 21);
});
test('avancer la dernière phase produit une dérive négative', () => {
const fige = figerReference(base, '2026-06-12');
const avance = remplacerPhase(fige, 'dev', (p) => deplacerPhase(p, '2026-08-25'));
assert.equal(deriveProjet(avance), -7);
});
test('décaler une phase qui n est pas la dernière ne dérive pas', () => {
const fige = figerReference(base, '2026-06-12');
const glisse = remplacerPhase(fige, 'cadrage', (p) => deplacerPhase(p, '2026-08-08'));
assert.equal(deriveProjet(glisse), 0);
});
test('une phase ajoutée après le figeage compte dans la dérive', () => {
// Elle n'a pas de référence, donc pas d'effet sur la fin de référence,
// mais elle repousse la fin actuelle : c'est bien un glissement.
const fige = figerReference(base, '2026-06-12');
const augmente = ajouterPhase(
fige,
creerPhase(fige, 'Recette', '2026-11-01', '2026-11-30')
);
assert.equal(deriveProjet(augmente), 31);
});
test('les bornes de référence ignorent les phases sans référence', () => {
const fige = figerReference(base, '2026-06-12');
const augmente = ajouterPhase(fige, creerPhase(fige, 'Recette', '2026-11-01', '2026-11-30'));
assert.deepEqual(bornesReference(augmente), { start: '2026-08-01', end: '2026-10-30' });
assert.equal(bornesReference(base), null);
});
test('libérer retire la référence partout', () => {
const libere = libererReference(figerReference(base, '2026-06-12'));
assert.equal(deriveProjet(libere), null);
assert.ok(libere.phases.every((p) => p.baseline === undefined));
// pourEcriture doit faire disparaître baselineDate du fichier.
const ecrit = pourEcriture({ projects: [libere] });
assert.ok(!('baselineDate' in ecrit.projects[0]));
});
test('refiger après un arbitrage remet la dérive à zéro', () => {
const fige = figerReference(base, '2026-06-12');
const glisse = remplacerPhase(fige, 'dev', (p) => deplacerPhase(p, '2026-09-22'));
assert.equal(deriveProjet(figerReference(glisse, '2026-07-01')), 0);
});
test('formulerDerive arrondit en semaines et signe le résultat', () => {
assert.equal(formulerDerive(null), null);
assert.equal(formulerDerive(0), null);
assert.equal(formulerDerive(21), '+3 sem');
assert.equal(formulerDerive(-14), '2 sem');
// Sous la demi-semaine on annonce les jours, pour éviter un « 0 semaine ».
assert.equal(formulerDerive(2), '+2 j');
assert.equal(formulerDerive(-3), '3 j');
});
});
// --- modifications ----------------------------------------------------------
describe('modifications', () => {
test('déplacer conserve la durée', () => {
const depart = phase({ start: '2026-08-01', end: '2026-08-20' });
const arrivee = deplacerPhase(depart, '2026-09-01');
assert.equal(arrivee.start, '2026-09-01');
assert.equal(arrivee.end, '2026-09-20');
assert.equal(duree(arrivee), duree(depart));
});
test('déplacer un jalon le laisse sur un seul jour', () => {
const jalon = phase({ milestone: true, start: '2026-11-02', end: '2026-11-02' });
const deplace = deplacerPhase(jalon, '2026-12-01');
assert.equal(deplace.start, '2026-12-01');
assert.equal(deplace.end, '2026-12-01');
});
test('redimensionner change une seule borne', () => {
const depart = phase({ start: '2026-08-01', end: '2026-08-20' });
assert.equal(redimensionnerPhase(depart, 'debut', '2026-07-15').start, '2026-07-15');
assert.equal(redimensionnerPhase(depart, 'debut', '2026-07-15').end, '2026-08-20');
assert.equal(redimensionnerPhase(depart, 'fin', '2026-09-30').end, '2026-09-30');
assert.equal(redimensionnerPhase(depart, 'fin', '2026-09-30').start, '2026-08-01');
});
test('une phase ne peut pas se retourner en la redimensionnant', () => {
const depart = phase({ start: '2026-08-01', end: '2026-08-20' });
assert.equal(redimensionnerPhase(depart, 'debut', '2026-09-30').start, '2026-08-20');
assert.equal(redimensionnerPhase(depart, 'fin', '2026-07-01').end, '2026-08-01');
});
test('un jalon ne se redimensionne pas', () => {
const jalon = phase({ milestone: true, start: '2026-11-02', end: '2026-11-02' });
assert.deepEqual(redimensionnerPhase(jalon, 'fin', '2026-12-01'), jalon);
});
test('cocher « jalon » ramène la fin sur le début', () => {
const modifiee = modifierPhase(phase({ start: '2026-08-01', end: '2026-08-20' }), {
milestone: true,
});
assert.equal(modifiee.end, '2026-08-01');
});
test('saisir une fin antérieure au début conserve la durée précédente', () => {
const depart = phase({ start: '2026-08-01', end: '2026-08-20' }); // 20 jours
const modifiee = modifierPhase(depart, { start: '2026-09-01', end: '2026-08-05' });
assert.equal(modifiee.start, '2026-09-01');
assert.equal(modifiee.end, '2026-09-20');
});
test('creerPhase engendre un identifiant unique dans le projet', () => {
let p = projet();
p = ajouterPhase(p, creerPhase(p, 'Cadrage', '2026-08-01', '2026-08-20'));
p = ajouterPhase(p, creerPhase(p, 'Cadrage', '2026-09-01', '2026-09-20'));
assert.deepEqual(p.phases.map((x) => x.id), ['cadrage', 'cadrage-2']);
});
test('creerPhase force un jalon sur un seul jour', () => {
const jalon = creerPhase(projet(), 'Livraison', '2026-11-02', '2026-12-31', {
milestone: true,
});
assert.equal(jalon.end, '2026-11-02');
});
test('creerProjet engendre un identifiant unique et parcourt la palette', () => {
let planning = planningVide();
const premier = creerProjet(planning, 'Site web');
planning = { ...planning, projects: [premier] };
const second = creerProjet(planning, 'Site web');
assert.equal(premier.id, 'site-web');
assert.equal(second.id, 'site-web-2');
assert.notEqual(premier.color, second.color);
assert.equal(premier.notes, '');
});
test('remplacerPhase retrie quand un déplacement change l ordre', () => {
const p = projet({
phases: [
phase({ id: 'a', name: 'A', start: '2026-08-01', end: '2026-08-20' }),
phase({ id: 'b', name: 'B', start: '2026-09-01', end: '2026-09-20' }),
],
});
const apres = remplacerPhase(p, 'a', (x) => deplacerPhase(x, '2026-10-01'));
assert.deepEqual(apres.phases.map((x) => x.id), ['b', 'a']);
});
test('un planning modifié survit à un aller-retour par la validation', () => {
// Garantit que ce qu'on écrit sur le disque est relisible sans perte.
let planning = planningVide();
const p = creerProjet(planning, 'Site web');
planning = { ...planning, projects: [p] };
planning = {
...planning,
projects: [
figerReference(
ajouterPhase(p, creerPhase(p, 'Cadrage', '2026-08-01', '2026-08-20')),
'2026-06-12'
),
],
};
const relu = validerPlanning(pourEcriture(planning));
assert.deepEqual(relu, validerPlanning(pourEcriture(relu)));
assert.deepEqual(relu.projects[0].phases[0].baseline, {
start: '2026-08-01',
end: '2026-08-20',
});
});
});
// --- décalage d'un projet ---------------------------------------------------
// --- libellé d'une phase ----------------------------------------------------
describe('libelleStatut', () => {
test('rend le statut seul quand la date nous appartient', () => {
assert.equal(libelleStatut(phase({ status: 'doing' })), 'En cours');
});
test('accole la date imposée au statut, sans le remplacer', () => {
const libelle = libelleStatut(phase({ status: 'blocked', fixed: true }));
assert.match(libelle, /^Bloqué — date imposée/);
});
// Le validateur ramène un statut inconnu à « à venir » plutôt que de refuser
// le fichier : le libellé fait le même choix, faute de quoi une vue afficherait
// « undefined » là où la frise dessine une barre.
test('retombe sur « à venir » pour un statut inconnu', () => {
assert.equal(libelleStatut(phase({ status: 'zzz' })), 'À venir');
});
});
describe('décaler un projet', () => {
const enCours = projet({
phases: [
phase({ id: 'cadrage', start: '2026-08-03', end: '2026-08-21', status: 'done' }),
phase({ id: 'chantier', start: '2026-08-24', end: '2026-10-02', status: 'doing' }),
phase({
id: 'livraison',
start: '2026-10-12',
end: '2026-10-12',
status: 'todo',
milestone: true,
}),
],
});
const avecImposee = projet({
phases: [
phase({ id: 'chantier', start: '2026-08-24', end: '2026-10-02', status: 'doing' }),
phase({
id: 'audit-externe',
start: '2026-09-07',
end: '2026-09-18',
status: 'todo',
fixed: true,
}),
],
});
test('emporte tout ce qui n est pas terminé, et rien d autre', () => {
assert.deepEqual(
phasesDecalables(enCours).map((p) => p.id),
['chantier', 'livraison']
);
});
test('n emporte pas une phase à date imposée, quel que soit son statut', () => {
assert.deepEqual(
phasesDecalables(avecImposee).map((p) => p.id),
['chantier']
);
// Le statut ne rachète rien : c'est bien la date qui est tenue, pas le
// travail qui serait fini.
const enCoursEtImposee = projet({
phases: [phase({ status: 'doing', fixed: true })],
});
assert.deepEqual(phasesDecalables(enCoursEtImposee), []);
});
test('une phase à date imposée reste en place quand le projet est repoussé', () => {
const repousse = decalerProjet(avecImposee, 30);
const audit = repousse.phases.find((p) => p.id === 'audit-externe');
const chantier = repousse.phases.find((p) => p.id === 'chantier');
assert.deepEqual(
{ start: audit.start, end: audit.end },
{ start: '2026-09-07', end: '2026-09-18' }
);
assert.equal(chantier.start, '2026-09-23');
// Le chantier passe désormais par-dessus l'audit : c'est l'information que
// le geste doit faire apparaître, pas un cas à empêcher.
assert.ok(ecartJours(chantier.start, audit.end) < 0);
});
test('un projet dont tout est imposé ou terminé n offre aucune prise', () => {
const tenu = projet({
phases: [
phase({ id: 'a', status: 'done' }),
phase({ id: 'b', start: '2026-09-01', end: '2026-09-10', fixed: true }),
],
});
assert.equal(ancreDecalage(tenu), null);
assert.deepEqual(decalerProjet(tenu, 10).phases, tenu.phases);
});
test('s ancre sur la première phase qui bouge, pas sur le début du projet', () => {
// Le projet commence le 3 août, mais ce début est clos : c'est le 24 qui
// suit le pointeur.
assert.equal(ancreDecalage(enCours), '2026-08-24');
assert.equal(ancreDecalage(projet({ phases: [phase()] })), '2026-08-01');
});
test('un projet dont tout est terminé n a pas d ancre', () => {
const clos = projet({ phases: [phase({ status: 'done' })] });
assert.equal(ancreDecalage(clos), null);
assert.equal(ancreDecalage(projet()), null, 'ni un projet sans phase');
});
test('applique le même delta partout : les écarts sont conservés au jour près', () => {
const repousse = decalerProjet(enCours, 14);
const [cadrage, chantier, livraison] = repousse.phases;
assert.deepEqual(
{ start: cadrage.start, end: cadrage.end },
{ start: '2026-08-03', end: '2026-08-21' },
'la phase terminée reste où elle est'
);
assert.deepEqual(
{ start: chantier.start, end: chantier.end },
{ start: '2026-09-07', end: '2026-10-16' }
);
assert.deepEqual(
{ start: livraison.start, end: livraison.end },
{ start: '2026-10-26', end: '2026-10-26' },
'le jalon suit, et tient toujours sur un jour'
);
assert.equal(
ecartJours(chantier.end, livraison.start),
ecartJours(enCours.phases[1].end, enCours.phases[2].start)
);
});
test('recule aussi bien qu il avance', () => {
const recule = decalerProjet(enCours, -7);
assert.equal(recule.phases[1].start, '2026-08-17');
// Rien n'interdit de chevaucher une phase terminée : il n'y a pas de
// dépendances entre phases, et une butée en inventerait une.
assert.ok(ecartJours(recule.phases[1].start, recule.phases[0].end) > 0);
});
test('un décalage nul rend le projet inchangé', () => {
assert.equal(decalerProjet(enCours, 0), enCours);
});
test('la référence figée ne suit pas : c est elle qui mesure la dérive', () => {
const fige = figerReference(enCours, '2026-08-01');
const repousse = decalerProjet(fige, 21);
assert.deepEqual(repousse.phases[1].baseline, { start: '2026-08-24', end: '2026-10-02' });
assert.equal(deriveProjet(repousse), 21);
});
test('retrie les phases quand le décalage change leur ordre', () => {
const desordonne = projet({
phases: [
phase({ id: 'a', start: '2026-08-01', end: '2026-08-05', status: 'todo' }),
phase({ id: 'b', start: '2026-08-10', end: '2026-08-15', status: 'done' }),
],
});
assert.deepEqual(
decalerProjet(desordonne, 30).phases.map((p) => p.id),
['b', 'a']
);
});
});
// --- cycle de vie -----------------------------------------------------------
describe('cycle de vie', () => {
const envisage = projet({ horizon: { start: '2027-03', end: '2027-06' } });
const engage = projet({ horizon: { start: '2027-03', end: '2027-06' }, phases: [phase()] });
test('l état se déduit des phases tant que rien n a été prononcé', () => {
assert.equal(etatProjet(envisage), 'considered');
assert.equal(etatProjet(projet()), 'considered', 'même sans horizon');
assert.equal(etatProjet(engage), 'engaged');
});
test('terminer et écarter sont des actes, et ils horodatent', () => {
const clos = terminerProjet(engage, '2027-11-08');
assert.equal(etatProjet(clos), 'completed');
assert.equal(clos.completedDate, '2027-11-08');
const ecarte = ecarterProjet(envisage, '2026-09-14');
assert.equal(etatProjet(ecarte), 'discarded');
assert.equal(ecarte.discardedDate, '2026-09-14');
});
test('clore le projet termine toutes ses phases, jalons compris', () => {
// Sans cela la frise se contredirait : une ligne éteinte au-dessus de
// segments pâles, et des tâches « à venir » listées dans un projet fini.
const enCours = projet({
phases: [
phase({ id: 'a', name: 'A', status: 'done' }),
phase({ id: 'b', name: 'B', status: 'blocked' }),
phase({ id: 'c', name: 'C', status: 'todo', milestone: true, end: '2026-08-01' }),
],
});
const clos = terminerProjet(enCours, '2027-11-08');
assert.deepEqual(
clos.phases.map((p) => p.status),
['done', 'done', 'done']
);
assert.equal(clos.phases[2].milestone, true, 'un jalon reste un jalon');
});
test('rouvrir ne rend pas aux phases le statut qu elles avaient', () => {
// L'écrasement est le prix assumé de la clôture, et la raison pour laquelle
// l'interface la fait confirmer.
const enCours = projet({ phases: [phase({ status: 'blocked' })] });
const rouvert = rouvrirProjet(terminerProjet(enCours));
assert.equal(etatProjet(rouvert), 'engaged');
assert.equal(rouvert.phases[0].status, 'done');
});
test('clotureEcraseDesStatuts ne prévient que s il reste quelque chose à écraser', () => {
assert.ok(clotureEcraseDesStatuts(projet({ phases: [phase({ status: 'todo' })] })));
assert.ok(!clotureEcraseDesStatuts(projet({ phases: [phase({ status: 'done' })] })));
assert.ok(!clotureEcraseDesStatuts(projet()), 'aucune phase, rien à écraser');
});
test('des phases toutes finies ne closent pas le projet à elles seules', () => {
// Livré n'est pas clos : c'est quelqu'un qui le prononce.
const livre = projet({ phases: [phase({ status: 'done' })] });
assert.equal(etatProjet(livre), 'engaged');
});
test('un projet ne porte jamais les deux dates à la fois', () => {
const clos = terminerProjet(engage, '2027-11-08');
const puisEcarte = ecarterProjet(clos, '2027-12-01');
assert.equal(puisEcarte.completedDate, undefined);
assert.equal(etatProjet(puisEcarte), 'discarded');
});
test("l'écartement l'emporte sur la clôture dans un fichier retouché à la main", () => {
const incoherent = projet({ completedDate: '2027-01-01', discardedDate: '2027-02-01' });
assert.equal(etatProjet(incoherent), 'discarded');
});
test('réanimer rend au projet l état que ses données commandent', () => {
// Aucune destination à choisir : c'est tout l'intérêt d'un état déduit.
assert.equal(etatProjet(reanimerProjet(ecarterProjet(engage))), 'engaged');
assert.equal(etatProjet(reanimerProjet(ecarterProjet(envisage))), 'considered');
});
test('rouvrir annule la seule clôture, sans retirer ni ajouter de phase', () => {
const rouvert = rouvrirProjet(terminerProjet(engage, '2027-11-08'));
assert.equal(etatProjet(rouvert), 'engaged');
assert.deepEqual(
rouvert.phases.map((p) => p.id),
engage.phases.map((p) => p.id)
);
// Leur statut, lui, a été écrasé par la clôture — voir le test dédié.
});
test('un projet qui perd sa dernière phase redevient envisagé', () => {
// Le seul retour arrière du cycle, et il n'a rien à déclarer.
const vide = supprimerPhase(engage, 'cadrage');
assert.equal(etatProjet(vide), 'considered');
assert.deepEqual(empriseProjet(vide), { start: '2027-03-01', end: '2027-06-30' });
});
test('creerProjet fait naître un projet envisagé, ancré sur le mois courant', () => {
const p = creerProjet(planningVide(), 'Salle des fêtes', [], '2026-08');
assert.equal(etatProjet(p), 'considered');
assert.deepEqual(p.horizon, { start: '2026-08', end: '2026-08' });
});
test('projetFiltreEtat élargit à chaque état coché, contrairement aux tags', () => {
assert.ok(projetFiltreEtat(envisage, ['considered']));
assert.ok(!projetFiltreEtat(envisage, ['engaged']));
assert.ok(projetFiltreEtat(envisage, ['considered', 'engaged']));
assert.ok(projetFiltreEtat(engage, ['considered', 'engaged']));
assert.ok(projetFiltreEtat(envisage, []), 'une liste vide laisse tout passer');
});
test('la vue par mois écarte les projets dont l état n est pas coché', () => {
const planning = {
projects: [
projet({ id: 'vivant', name: 'Vivant', phases: [phase({ start: '2026-08-01' })] }),
projet({
id: 'mort',
name: 'Mort',
discardedDate: '2026-07-01',
phases: [phase({ id: 'p', start: '2026-08-05' })],
}),
],
};
const tout = entreesParMois(planning, [], []);
assert.equal(tout[0].entrees.length, 2);
const sansEcartes = entreesParMois(planning, [], ['considered', 'engaged', 'completed']);
assert.deepEqual(
sansEcartes[0].entrees.map((e) => e.projet.id),
['vivant']
);
});
});
// --- horizon ----------------------------------------------------------------
describe('horizon', () => {
const horizon = { start: '2027-03', end: '2027-06' };
test('les bornes d un horizon couvrent ses mois entiers', () => {
assert.deepEqual(bornesHorizon(projet({ horizon })), {
start: '2027-03-01',
end: '2027-06-30',
});
assert.equal(bornesHorizon(projet()), null);
});
test('les phases commandent l emprise, l horizon n est qu un repli', () => {
const avecPhases = projet({ horizon, phases: [phase()] });
assert.deepEqual(empriseProjet(avecPhases), { start: '2026-08-01', end: '2026-08-20' });
assert.deepEqual(empriseProjet(projet({ horizon })), {
start: '2027-03-01',
end: '2027-06-30',
});
assert.equal(empriseProjet(projet()), null, 'ni phase ni horizon');
});
test('definirHorizon remet une fin antérieure au début d aplomb', () => {
// La saisie se fait au fil de la frappe : un intervalle à l'envers est un
// état de passage, pas une erreur à signaler.
assert.deepEqual(definirHorizon(projet(), '2027-06', '2027-03').horizon, {
start: '2027-06',
end: '2027-06',
});
assert.deepEqual(definirHorizon(projet(), '2027-03').horizon, {
start: '2027-03',
end: '2027-03',
});
});
test('decalerHorizon conserve la largeur de l intervalle', () => {
const decale = decalerHorizon(projet({ horizon }), 6);
assert.deepEqual(decale.horizon, { start: '2027-09', end: '2027-12' });
assert.deepEqual(decalerHorizon(projet({ horizon }), -3).horizon, {
start: '2026-12',
end: '2027-03',
});
assert.deepEqual(decalerHorizon(projet(), 6), projet(), 'sans horizon, rien à décaler');
});
test('un horizon dépassé réclame une date, un horizon à venir non', () => {
assert.ok(!projetADater(projet({ horizon }), '2027-04'), 'on est dedans');
assert.ok(!projetADater(projet({ horizon }), '2027-06'), 'dernier mois inclus');
assert.ok(projetADater(projet({ horizon }), '2027-07'), 'dépassé');
});
test('un projet envisagé sans horizon réclame une date lui aussi', () => {
// C'est le cas d'un projet hérité d'un fichier en version 4 : les deux
// manques se signalent pareil.
assert.ok(projetADater(projet(), '2026-08'));
});
test('un projet engagé, clos ou écarté ne réclame jamais de date', () => {
assert.ok(!projetADater(projet({ horizon, phases: [phase()] }), '2030-01'));
assert.ok(!projetADater(projet({ horizon, completedDate: '2027-01-01' }), '2030-01'));
assert.ok(!projetADater(projet({ horizon, discardedDate: '2027-01-01' }), '2030-01'));
});
test('la fenêtre du planning tient compte des horizons', () => {
// Sans cela, un planning fait de projets encore tous envisagés s'ouvrirait
// sur rien.
const planning = { projects: [projet({ horizon })] };
assert.deepEqual(bornesPlanning(planning), { start: '2027-03-01', end: '2027-06-30' });
});
test('la fenêtre du planning ignore les projets écartés', () => {
const planning = {
projects: [
projet({ id: 'vivant', phases: [phase()] }),
projet({
id: 'mort',
discardedDate: '2026-07-01',
phases: [phase({ id: 'p', start: '2019-01-01', end: '2019-06-01' })],
}),
],
};
assert.deepEqual(bornesPlanning(planning), { start: '2026-08-01', end: '2026-08-20' });
});
});
// --- validation du cycle de vie ---------------------------------------------
describe('validerPlanning : horizon et états', () => {
const avec = (champs) => ({ version: 5, projects: [projet(champs)] });
test('accepte un horizon correct et le conserve tel quel', () => {
const relu = validerPlanning(avec({ horizon: { start: '2027-03', end: '2027-06' } }));
assert.deepEqual(relu.projects[0].horizon, { start: '2027-03', end: '2027-06' });
});
test('laisse sans horizon un projet qui n en a pas', () => {
// C'est le cas de tous les projets d'un fichier en version 4.
const relu = validerPlanning(avec({}));
assert.ok(!('horizon' in relu.projects[0]));
assert.equal(etatProjet(relu.projects[0]), 'considered');
});
test('rejette un horizon mal formé plutôt que de l avaler', () => {
// Contrairement au statut ou aux notes : l'horizon décide d'une position sur
// la frise, et l'avaler laisserait un projet invisible sans raison visible.
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-13', end: '2027-06' } })),
(err) => err instanceof ErreurValidation && /horizon/.test(err.message)
);
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-03-01', end: '2027-06' } })),
ErreurValidation
);
assert.throws(() => validerPlanning(avec({ horizon: 'mars 2027' })), ErreurValidation);
});
test('rejette un horizon dont la fin précède le début, en nommant le fautif', () => {
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-06', end: '2027-03' } })),
(err) => err instanceof ErreurValidation && /« Site web »/.test(err.message)
);
});
test('rejette une date d acte invalide', () => {
assert.throws(
() => validerPlanning(avec({ completedDate: '2027-02-30' })),
(err) => err instanceof ErreurValidation && /completedDate/.test(err.message)
);
assert.throws(
() => validerPlanning(avec({ discardedDate: 'hier' })),
(err) => err instanceof ErreurValidation && /discardedDate/.test(err.message)
);
});
test('un projet du cycle de vie survit à un aller-retour par la validation', () => {
let planning = planningVide();
const p = ecarterProjet(
definirHorizon(creerProjet(planning, 'Salle des fêtes', [], '2026-08'), '2027-03', '2027-06'),
'2026-09-14'
);
planning = { ...planning, projects: [p] };
const relu = validerPlanning(pourEcriture(planning));
assert.deepEqual(relu, validerPlanning(pourEcriture(relu)));
assert.deepEqual(relu.projects[0].horizon, { start: '2027-03', end: '2027-06' });
assert.equal(relu.projects[0].discardedDate, '2026-09-14');
assert.ok(!('completedDate' in relu.projects[0]), 'les clés undefined ne sont pas écrites');
});
});