Glisser la barre résumé décale toutes les phases du même nombre de jours, écarts conservés : un projet engagé glisse souvent, et le faire phase par phase les déformait. Restent en place ce qui est terminé — on ne réécrit pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour ce qui est délégué ou tenu du dehors. D'où le format en version 6 : sans l'incrément, un binaire antérieur effacerait ces dates imposées à la première sauvegarde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1359 lines
51 KiB
JavaScript
1359 lines
51 KiB
JavaScript
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,
|
||
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 ---------------------------------------------------
|
||
|
||
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');
|
||
});
|
||
});
|