import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { ErreurValidation, MAX_LONGUEUR_TAG, VERSION_FORMAT, ajouterJours, ajouterPhase, bornesPlanning, bornesProjet, bornesReference, cleTag, creerPhase, creerProjet, dateValide, deplacerPhase, dimancheDeLaSemaine, deriveProjet, duree, ecartJours, fabriquerId, figerReference, formaterDateLongue, formaterMois, formaterPeriode, formulerDerive, libererReference, lundiDeLaSemaine, modifierPhase, normaliserTags, phasesParMois, planningVide, pourEcriture, projetFiltre, redimensionnerPhase, remplacerPhase, semaineISO, teinteTag, 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'); }); }); // --- 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('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('phasesParMois', () => { 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 = phasesParMois(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(phasesParMois(planning).some((groupe) => groupe.entrees.length === 0), false); assert.equal(cles(phasesParMois(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(phasesParMois(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(phasesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']); }); test('applique le filtre par tags, et écarte les projets masqués', () => { assert.deepEqual(cles(phasesParMois(planning, ['interne'])), ['2026-09']); assert.deepEqual(noms(phasesParMois(planning, ['interne'])[0]), ['Rédaction']); const masque = { projects: planning.projects.map((p) => p.id === 'portail' ? { ...p, hidden: true } : p ), }; assert.deepEqual(noms(phasesParMois(masque)[0]), ['Cadrage']); }); test('rend une liste vide plutôt que null quand rien ne reste', () => { assert.deepEqual(phasesParMois(planningVide()), []); assert.deepEqual(phasesParMois(planning, ['inconnu']), []); }); }); // --- 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', }); }); });