Files
gestion_projets/tests/markdown.test.js
Bertrand Benjamin e04877daae Analyser un sous-ensemble de markdown, sans dépendance
Les notes sont du texte brut : une liste s'y écrit, rien ne la dessine. Une
bibliothèque aurait été le premier `node_modules` de l'outil (décision 10) pour
une syntaxe dont une note de suivi n'emploie qu'une poignée de formes.

Le module ne touche ni au DOM ni au réseau — il transforme du texte en arbre,
comme `model.js` transforme des objets — ce qui le rend testable sous Node et
laisse le rendu à qui l'affichera.

Deux écarts au standard : un retour à la ligne en est un, et ce qui n'est pas
reconnu reste du texte, une note à moitié écrite étant l'état normal d'une note.
Les URL passent une liste blanche de schémas, un planning s'échangeant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:29:15 +02:00

253 lines
9.5 KiB
JavaScript

import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
analyserInline,
analyserMarkdown,
aplatirMarkdown,
basculerCase,
urlSure,
} from '../js/markdown.js';
// Ce module ne touche pas au DOM : c'est ce qui le rend testable ici, le rendu
// proprement dit vivant dans `notes.js`.
// --- blocs ------------------------------------------------------------------
describe('analyserMarkdown, blocs', () => {
test('reconnaît un titre et son niveau', () => {
const [bloc] = analyserMarkdown('### Point hebdo');
assert.equal(bloc.type, 'titre');
assert.equal(bloc.niveau, 3);
assert.deepEqual(bloc.contenu, [{ type: 'texte', valeur: 'Point hebdo' }]);
});
test('un dièse sans espace n est pas un titre', () => {
const [bloc] = analyserMarkdown('#12 du bon de commande');
assert.equal(bloc.type, 'paragraphe');
});
test('garde les retours à la ligne d un paragraphe', () => {
// Markdown standard recolle ces deux lignes ; on ne le suit pas ici, voir
// le commentaire d'`analyserInlineMultiligne`.
const [bloc] = analyserMarkdown('première ligne\nseconde ligne');
assert.equal(bloc.contenu.filter((noeud) => noeud.type === 'saut').length, 1);
});
test('sépare deux paragraphes par une ligne vide', () => {
const blocs = analyserMarkdown('un\n\ndeux');
assert.equal(blocs.length, 2);
assert.equal(blocs[0].type, 'paragraphe');
assert.equal(blocs[1].type, 'paragraphe');
});
test('distingue le séparateur d une liste à tirets', () => {
assert.equal(analyserMarkdown('---')[0].type, 'separateur');
assert.equal(analyserMarkdown('- ceci')[0].type, 'liste');
});
test('un bloc de code prend son contenu à la lettre', () => {
const [bloc] = analyserMarkdown('```js\nconst a = **b**;\n```');
assert.equal(bloc.type, 'code');
assert.equal(bloc.langage, 'js');
assert.deepEqual(bloc.lignes, ['const a = **b**;']);
});
test('un bloc de code non refermé court jusqu à la fin', () => {
// On est peut-être en train de l'écrire : avaler la suite est moins
// déroutant que d'afficher la clôture ouvrante comme un paragraphe.
const [bloc] = analyserMarkdown('```\nen cours');
assert.equal(bloc.type, 'code');
assert.deepEqual(bloc.lignes, ['en cours']);
});
test('une citation est réanalysée, listes comprises', () => {
const [bloc] = analyserMarkdown('> Le client dit :\n> - un\n> - deux');
assert.equal(bloc.type, 'citation');
assert.equal(bloc.blocs[0].type, 'paragraphe');
assert.equal(bloc.blocs[1].type, 'liste');
assert.equal(bloc.blocs[1].elements.length, 2);
});
});
// --- listes -----------------------------------------------------------------
describe('analyserMarkdown, listes', () => {
test('sépare une liste à puces d une liste numérotée', () => {
const blocs = analyserMarkdown('- a\n1. b');
assert.equal(blocs.length, 2);
assert.equal(blocs[0].ordonnee, false);
assert.equal(blocs[1].ordonnee, true);
});
test('détache les cases à cocher', () => {
const [bloc] = analyserMarkdown('- [ ] à faire\n- [x] fait\n- ni l un ni l autre');
assert.deepEqual(
bloc.elements.map((element) => element.coche),
[false, true, null]
);
assert.deepEqual(bloc.elements[0].contenu, [{ type: 'texte', valeur: 'à faire' }]);
});
test('imbrique une sous-liste à l indentation', () => {
const [bloc] = analyserMarkdown('- parent\n - enfant');
assert.equal(bloc.elements.length, 1);
assert.equal(bloc.elements[0].blocs[0].type, 'liste');
assert.equal(bloc.elements[0].blocs[0].elements[0].contenu[0].valeur, 'enfant');
});
test('chaque élément porte le numéro de sa ligne source', () => {
// C'est ce numéro qui permet à `basculerCase` de réécrire la bonne ligne,
// y compris dans une sous-liste.
const [, bloc] = analyserMarkdown('intro\n\n- [ ] premier\n- [x] second\n - [ ] fils');
assert.equal(bloc.elements[0].ligne, 2);
assert.equal(bloc.elements[1].ligne, 3);
assert.equal(bloc.elements[1].blocs[0].elements[0].ligne, 4);
});
});
// --- contenu d une ligne ----------------------------------------------------
describe('analyserInline', () => {
test('reconnaît le gras, l italique et le code', () => {
const noeuds = analyserInline('**a** *b* `c`');
assert.deepEqual(
noeuds.filter((noeud) => noeud.type !== 'texte').map((noeud) => noeud.type),
['gras', 'italique', 'code']
);
});
test('le code littéral l emporte sur l emphase', () => {
const [noeud] = analyserInline('`**pas du gras**`');
assert.equal(noeud.type, 'code');
assert.equal(noeud.valeur, '**pas du gras**');
});
test('laisse intact un identifiant à tirets bas', () => {
// Sans la garde d'`ouvreEmphase`, le milieu passerait en italique.
const noeuds = analyserInline('date_debut_reelle');
assert.deepEqual(noeuds, [{ type: 'texte', valeur: 'date_debut_reelle' }]);
});
test('laisse intact un astérisque isolé', () => {
assert.deepEqual(analyserInline('3 * 4 = 12'), [{ type: 'texte', valeur: '3 * 4 = 12' }]);
});
test('une marque non refermée reste du texte', () => {
// Une note à moitié écrite est l'état normal d'une note.
assert.deepEqual(analyserInline('**en cours'), [{ type: 'texte', valeur: '**en cours' }]);
});
test('la contre-oblique neutralise la marque suivante', () => {
assert.deepEqual(analyserInline('\\*littéral\\*'), [{ type: 'texte', valeur: '*littéral*' }]);
});
test('lit un lien et son libellé', () => {
const [noeud] = analyserInline('[le CR](https://ex.fr/cr)');
assert.equal(noeud.type, 'lien');
assert.equal(noeud.url, 'https://ex.fr/cr');
assert.deepEqual(noeud.contenu, [{ type: 'texte', valeur: 'le CR' }]);
});
test('des crochets seuls ne font pas un lien', () => {
assert.deepEqual(analyserInline('[à voir]'), [{ type: 'texte', valeur: '[à voir]' }]);
});
test('un lien au schéma refusé retombe sur son libellé', () => {
// Le texte survit, le lien non : effacer la ligne punirait le lecteur d'une
// note qu'il n'a pas écrite.
const [noeud] = analyserInline('[clic](javascript:alert(1))');
assert.equal(noeud.type, 'texte');
assert.equal(noeud.valeur, 'clic');
});
});
describe('urlSure', () => {
test('accepte http, https et mailto', () => {
assert.equal(urlSure('https://ex.fr'), 'https://ex.fr');
assert.equal(urlSure('http://ex.fr'), 'http://ex.fr');
assert.equal(urlSure('mailto:a@ex.fr'), 'mailto:a@ex.fr');
});
test('refuse les schémas exécutables, casse et espaces compris', () => {
// Liste blanche et non liste noire : un planning s'échange, et rien ne
// garantit que la note affichée a été écrite par celui qui la lit.
assert.equal(urlSure('javascript:alert(1)'), null);
assert.equal(urlSure(' JavaScript:alert(1)'), null);
assert.equal(urlSure('data:text/html,<script>'), null);
assert.equal(urlSure('vbscript:msgbox'), null);
});
test('laisse passer une adresse sans schéma', () => {
assert.equal(urlSure('docs/cr.pdf'), 'docs/cr.pdf');
});
});
// --- cases à cocher ---------------------------------------------------------
describe('basculerCase', () => {
const note = 'Titre\n\n- [ ] premier\n- [x] second\n - [ ] fils';
test('coche une case vide', () => {
assert.equal(basculerCase(note, 2).split('\n')[2], '- [x] premier');
});
test('décoche une case pleine', () => {
assert.equal(basculerCase(note, 3).split('\n')[3], '- [ ] second');
});
test('conserve l indentation d une sous-liste', () => {
assert.equal(basculerCase(note, 4).split('\n')[4], ' - [x] fils');
});
test('ne touche à rien d autre que la ligne visée', () => {
// On réécrit la seule ligne visée dans le texte d'origine : régénérer la
// note depuis l'arbre normaliserait au passage la mise en forme.
const lignes = basculerCase(note, 2).split('\n');
assert.equal(lignes[0], 'Titre');
assert.equal(lignes[3], '- [x] second');
assert.equal(lignes.length, 5);
});
test('laisse la note intacte si la ligne ne porte pas de case', () => {
assert.equal(basculerCase(note, 0), note);
assert.equal(basculerCase(note, 42), note);
});
});
// --- aplatissement ----------------------------------------------------------
describe('aplatirMarkdown', () => {
test('retire les marques d emphase et de titre', () => {
assert.equal(aplatirMarkdown('## Point\n\nRelance **client**'), 'Point\n\nRelance client');
});
test('remplace les marqueurs par des symboles qui portent le sens', () => {
// Un `title` ne connaît que le texte : sans cela, survoler afficherait la
// source markdown, astérisques comprises.
assert.equal(aplatirMarkdown('- [ ] à faire\n- [x] fait\n- simple'), '☐ à faire\n☑ fait\n• simple');
});
test('numérote une liste ordonnée', () => {
assert.equal(aplatirMarkdown('1. un\n1. deux'), '1. un\n2. deux');
});
test('indente une sous-liste', () => {
assert.equal(aplatirMarkdown('- parent\n - enfant'), '• parent\n • enfant');
});
test('marque une citation', () => {
assert.equal(aplatirMarkdown('> cité'), '│ cité');
});
test('écrit l adresse d un lien quand le libellé ne la dit pas', () => {
assert.equal(aplatirMarkdown('[CR](https://ex.fr)'), 'CR (https://ex.fr)');
assert.equal(aplatirMarkdown('[https://ex.fr](https://ex.fr)'), 'https://ex.fr');
});
test('rend une note vide sans rien produire', () => {
assert.equal(aplatirMarkdown(''), '');
assert.equal(aplatirMarkdown(' \n\n '), '');
});
});