Porter le cycle de vie et l'horizon dans le modèle, en version 5
L'état n'est pas un champ : il se lit dans horizon, completedDate, discardedDate et la présence de phases. Les phases commandent, sauf quand on a prononcé quelque chose. L'emprise d'un projet retombe sur son horizon faute de phases, et une saisie n'est jamais réécrite par un calcul : un projet qui perd sa dernière phase retrouve l'horizon qu'il avait déclaré. Clore un projet termine aussi toutes ses phases, seule opération du cycle qui écrase des données. phasesParMois devient entreesParMois : elle émet aussi les projets sans phase, à leur horizon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,37 +6,56 @@ import {
|
||||
MAX_LONGUEUR_TAG,
|
||||
VERSION_FORMAT,
|
||||
ajouterJours,
|
||||
ajouterMois,
|
||||
ajouterPhase,
|
||||
bornesHorizon,
|
||||
bornesPlanning,
|
||||
bornesProjet,
|
||||
bornesReference,
|
||||
cleTag,
|
||||
clotureEcraseDesStatuts,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
dateValide,
|
||||
decalerHorizon,
|
||||
definirHorizon,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
dimancheDeLaSemaine,
|
||||
deriveProjet,
|
||||
duree,
|
||||
ecarterProjet,
|
||||
ecartJours,
|
||||
ecartMois,
|
||||
empriseProjet,
|
||||
entreesParMois,
|
||||
etatProjet,
|
||||
fabriquerId,
|
||||
figerReference,
|
||||
formaterDateLongue,
|
||||
formaterHorizon,
|
||||
formaterMois,
|
||||
formaterPeriode,
|
||||
formulerDerive,
|
||||
libererReference,
|
||||
lundiDeLaSemaine,
|
||||
modifierPhase,
|
||||
moisValide,
|
||||
normaliserTags,
|
||||
phasesParMois,
|
||||
planningVide,
|
||||
pourEcriture,
|
||||
premierJourDuMois,
|
||||
projetADater,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
reanimerProjet,
|
||||
redimensionnerPhase,
|
||||
remplacerPhase,
|
||||
rouvrirProjet,
|
||||
semaineISO,
|
||||
supprimerPhase,
|
||||
teinteTag,
|
||||
terminerProjet,
|
||||
tousLesTags,
|
||||
validerPlanning,
|
||||
} from '../js/model.js';
|
||||
@@ -164,6 +183,57 @@ describe('dates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- 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', () => {
|
||||
@@ -458,7 +528,7 @@ describe('bornes', () => {
|
||||
|
||||
// --- regroupement par mois --------------------------------------------------
|
||||
|
||||
describe('phasesParMois', () => {
|
||||
describe('entreesParMois', () => {
|
||||
const planning = {
|
||||
version: VERSION_FORMAT,
|
||||
projects: [
|
||||
@@ -493,7 +563,7 @@ describe('phasesParMois', () => {
|
||||
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);
|
||||
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.
|
||||
@@ -501,8 +571,8 @@ describe('phasesParMois', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
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', () => {
|
||||
@@ -516,7 +586,7 @@ describe('phasesParMois', () => {
|
||||
}),
|
||||
],
|
||||
};
|
||||
assert.deepEqual(cles(phasesParMois(surDeuxAns)), ['2026-12', '2027-01']);
|
||||
assert.deepEqual(cles(entreesParMois(surDeuxAns)), ['2026-12', '2027-01']);
|
||||
});
|
||||
|
||||
test('trie un mois par date, puis par projet, puis par nom de phase', () => {
|
||||
@@ -537,24 +607,98 @@ describe('phasesParMois', () => {
|
||||
}),
|
||||
],
|
||||
};
|
||||
assert.deepEqual(noms(phasesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']);
|
||||
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(phasesParMois(planning, ['interne'])), ['2026-09']);
|
||||
assert.deepEqual(noms(phasesParMois(planning, ['interne'])[0]), ['Rédaction']);
|
||||
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(phasesParMois(masque)[0]), ['Cadrage']);
|
||||
assert.deepEqual(noms(entreesParMois(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']), []);
|
||||
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']
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -756,3 +900,288 @@ describe('modifications', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user