Une phase avait quatre lignes de texte brut ; un projet n'avait rien. Les deux
manques se tiennent : la frise ne dit pas qui pilote ni sur quel budget, et ce
contexte finissait dans les notes de la première phase venue, où il ne survit
pas à sa suppression.
Le bloc de notes est le même dans les deux panneaux et prend toute la hauteur
restante : les autres champs ont une taille dictée par leur contenu, une note
fait ce qu'on a à dire. Il s'ouvre sur l'aperçu quand la note existe, sur la
saisie quand elle est vide.
Les cases à cocher se cliquent dans l'aperçu — seul geste de l'aperçu qui touche
aux données. La bascule réécrit la seule ligne visée plutôt que de régénérer la
note, qui verrait sinon sa mise en forme normalisée. Inertes dans la vue par
mois, en lecture seule (décision 24).
La barre d'outils écrit par `document.execCommand('insertText')` et non en
affectant `value`, qui viderait la pile d'annulation du navigateur : `Ctrl+Z`
doit continuer de marcher. Le DOM est bâti nœud par nœud, sans un `innerHTML`.
Les infobulles aplatissent le markdown — `title` ne connaît que le texte —, et
celles d'un jalon et d'un libellé de projet les affichent désormais.
Le format passe en version 4 : le validateur reconstruit chaque projet champ par
champ, un binaire antérieur effacerait les notes de projet à la première
sauvegarde.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
759 lines
28 KiB
JavaScript
759 lines
28 KiB
JavaScript
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',
|
||
});
|
||
});
|
||
});
|