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'); }); });