{t(\'k.titre\')} — {42}
', '', '', '{`${x} places`}{x ?? \'Aucun\'}{x && \'Plein\'}', '{x} / {x}{t('k.present')}
\n{t(`k.gabarit`)}
\n{t(`k.${a}`)}
\n{traduction.t(\"k.membre\")}
", }), 'src/interface/plan/modele.js': [ '// t(\'k.commentaire\') ne compte pas.', "export const cle = (t) => t('k.module', { n: 1 });", "export const autre = (texte) => texte('k.texte');", '', ].join('\n'), 'src/interface/plan/modele.test.js': "t('k.epreuve');\n", }; assert.deepEqual(avecArbre(fichiers, (racine) => releverClesAbsentes(racine, cles)), [ 'src/interface/A.svelte:4 clé absente « k.absente »', 'src/interface/A.svelte:7 clé absente « k.gabarit »', 'src/interface/A.svelte:9 clé absente « k.membre »', 'src/interface/plan/modele.js:2 clé absente « k.module »', ]); }); test('un arbre sans source d’interface fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/application/a.js': '' }, (r) => releverClesAbsentes(r, CLES)), /aucun fichier examiné/); }); test('des sources d’interface sans aucune clé littérale passée à t font échouer la garde', () => { const fichiers = { 'src/interface/A.svelte': "{tr('k.absente')}
\n", 'src/interface/B.svelte': composant({ script: " import { getContext } from 'svelte';\n const traduire = getContext('t');", gabarit: "{traduire('plan.x')}
\n{t(cle)}
", }), 'src/interface/c.js': "export const d = (t, n) => t(`k.${n}`);\n", }; assert.throws(() => avecArbre(fichiers, (r) => releverClesAbsentes(r, CLES)), /clés de t : aucune clé examinée/); }); }); describe('sources de l’interface : le contenu des tables de traduction (§ 5.5, § 7.6, § 7.7, § 12.8)', () => { test('aucune valeur des tables assemblées ne porte un mot refusé ni un pictogramme', () => { exigerVide(releverContenuTables([...TABLES_APPLICATION, ...TABLES_INTERFACE], localisateur(RACINE)), 'contenu des tables'); }); test('la garde relève chaque mot refusé, sans égard à la casse ni aux accents, dans une chaîne et dans une fonction', () => { const refusees = new Map([ ['a.conforme', 'Conforme'], ['a.nonConforme', 'Plan non conforme'], ['a.conformite', 'CONFORMITÉ'], ['a.ecart', ({ n }) => `Un écart de ${n}`], ['a.ecartSeul', (details, texte) => texte('a.ecart', details)], ['a.ecartType', 'Écart-type'], ['a.satisfaction', 'Taux de satisfaction'], ['a.variance', 'variance'], ['a.equite', 'Équité'], ['a.score', 'Score'], ['a.mieux', 'Peut mieux faire'], ['a.minimal', 'Manque minimal'], ['a.recuperable', 'récupérable'], ['a.evitable', 'Évitable'], ['a.emoji', ({ n }) => `${n} \u{1F512}`], ['a.copyright', '\u{00A9} Club des Merles'], ]); const admises = new Map([ ['b.itineraire', 'Écart d\u{2019}itinéraire'], ['b.itineraires', "écarts d'itinéraire maximal"], ['b.plafond', 'Écart au plafond a priori'], ['b.inevitable', 'Inévitable'], ['b.ecartee', 'Proposition écartée'], ['b.fleche', 'Tour 1 \u{2192} tour 2'], ['b.nom', 'Iris Ombrelle'], ['b.variable', ({ n }) => { const ecart = n + 1; return `Tour ${ecart}`; }], ]); assert.deepEqual(releverContenuTables([refusees, admises], () => null, []), [ "a.conforme : conforme « Conforme »", 'a.conformite : conforme « CONFORMITÉ »', 'a.copyright : pictogramme U+00A9 « © Club des Merles »', 'a.ecart : écart hors du glossaire « Un écart de »', 'a.ecartType : écart hors du glossaire « Écart-type »', 'a.ecartType : écart-type « Écart-type »', 'a.emoji : pictogramme U+1F512 « \u{1F512} »', 'a.equite : équité « Équité »', 'a.evitable : évitable « Évitable »', 'a.mieux : peut mieux faire « Peut mieux faire »', 'a.minimal : manque minimal « Manque minimal »', 'a.nonConforme : conforme « Plan non conforme »', 'a.recuperable : récupérable « récupérable »', 'a.satisfaction : satisfaction « Taux de satisfaction »', 'a.score : score « Score »', 'a.variance : variance « variance »', ]); }); test('la garde nomme le fichier et la ligne de la clé fautive, et lève sur des tables vides', () => { const fichiers = { 'src/interface/libelles/plan.js': "export const TABLE = new Map([\n ['plan.a', 'A'],\n [\n 'plan.b',\n 'Score',\n ],\n]);\n", }; const releves = avecArbre(fichiers, (racine) => releverContenuTables([new Map([['plan.a', 'A'], ['plan.b', 'Score']])], localisateur(racine), []), ); assert.deepEqual(releves, ['src/interface/libelles/plan.js:3 plan.b : score « Score »']); assert.throws(() => releverContenuTables([new Map()]), /aucune entrée examinée/); }); test('une exception nommée n’admet que sa forme, sous ses clés ; tout autre « évitable » se relève, et une exception que rien ne sert est sans objet', () => { const tables = new Map([ ['diagnostic.remede.un', '2 chaises de plus la rend évitable'], ['diagnostic.remede.deux', ({ n }) => (n === 1 ? 'une table la rend évitable' : `${n} tables la rendent évitable`)], ['diagnostic.remede.reste', 'la rend évitable, part évitable'], ['diagnostic.remede.autre', 'la place devient évitable'], ['diagnostic.titre', 'un tabouret la rend évitable'], ['plan.segment', 'la rend évitable'], ['diagnostic.remede.inevitable', 'inévitable'], ]); assert.deepEqual(releverContenuTables([tables], () => null, exceptionsDu('évitable')), [ 'diagnostic.remede.autre : évitable « la place devient évitable »', 'diagnostic.remede.reste : évitable « la rend évitable, part évitable »', 'diagnostic.titre : évitable « un tabouret la rend évitable »', 'plan.segment : évitable « la rend évitable »', ]); const sansRemede = new Map([['diagnostic.remede.un', 'ajouter une table']]); assert.deepEqual(releverContenuTables([sansRemede], () => null, exceptionsDu('évitable')), [ 'exception sans objet : diagnostic.remede.* « la rend(ent) évitable »', ]); }); test('rouge : l’exception D22 n’admet « l’équité d’abord » que sous la clé du libellé du troisième mode ; tout autre emploi d’« équité » se relève, l’explication du mode comprise', () => { const tables = new Map([ ['generation.reglage.modeObjectifs.minimum', 'l\u{2019}équité d\u{2019}abord'], ['generation.reglage.modeObjectifs.minimum.explication', "l'équité d'abord, puis le total"], ['generation.reglage.modeObjectifs.chacun', "L'ÉQUITÉ D'ABORD"], ['propositions.phrase', "l'équité"], ['diagnostic.remede.un', '2 chaises de plus la rend évitable'], ]); assert.deepEqual(releverContenuTables([tables]), [ "generation.reglage.modeObjectifs.chacun : équité « L'ÉQUITÉ D'ABORD »", "generation.reglage.modeObjectifs.minimum.explication : équité « l'équité d'abord, puis le total »", "propositions.phrase : équité « l'équité »", ]); // Sous sa clé, une autre forme du mot se relève encore. const autre = new Map([ ['generation.reglage.modeObjectifs.minimum', "l'équité d'abord, une équité"], ['diagnostic.remede.un', 'la rend évitable'], ]); assert.deepEqual(releverContenuTables([autre]), [ "generation.reglage.modeObjectifs.minimum : équité « l'équité d'abord, une équité »", ]); const sansMode = new Map([['diagnostic.remede.un', 'la rend évitable']]); assert.deepEqual(releverContenuTables([sansMode]), [ "exception sans objet : generation.reglage.modeObjectifs.minimum « l'équité d'abord » (D22)", ]); }); }); // --- Garde : l'état des critères se lit, il ne se recalcule pas --------------- // Les sources qui écrivent l'état « décoché » ou « sans objet » d'un critère // (§ 5.3, A8) : l'éditeur des priorités, les réglages qui portent le choix du // mode, et leur modèle. const SOURCES_DE_L_ETAT = [ 'src/interface/generation/Priorites.svelte', 'src/interface/generation/Reglages.svelte', 'src/interface/generation/priorites.js', ]; // Ce que la garde relève d'un « sans objet » recalculé : une variable ou une // clé sansObjet qui reçoit une valeur, et chaque lecture des objets de la // charge d'où il se déduirait — tables, participants, titres, réservations, // objectifs, étiquettes, animations. Ces sources lisent l'état dans la table // d'évaluation, qui le tient du moteur (§ 13.2) : aucune n'en a l'emploi. Une // lecture est un accès pointé, un accès entre crochets par une chaîne, ou // une déstructuration qui nomme l'objet — un motif entre accolades suivi de // « = », hors « == » et « => », ou de la parenthèse d'un paramètre. Les // commentaires ne calculent rien et se retirent d'abord ; un commentaire de // ligne commence en début de ligne ou après un blanc. const OBJETS_DE_LA_CHARGE = '(?:tables|participants|titres|reservations|objectifs|etiquettes|anime)'; function relevesSansObjet(texte) { const source = texte .replace(/\/\*[\s\S]*?\*\//g, ' ') .replace(//g, ' ') .replace(/(^|\s)\/\/.*$/gm, ' '); const motifs = [ /\bsansObjet\s*(?::|=(?!=))/g, new RegExp(`\\.${OBJETS_DE_LA_CHARGE}\\b`, 'g'), new RegExp(`\\{[^{}]*\\b${OBJETS_DE_LA_CHARGE}\\b[^{}]*\\}\\s*(?:=(?![=>])|\\))`, 'g'), new RegExp(`\\[\\s*(['"\`])${OBJETS_DE_LA_CHARGE}\\1\\s*\\]`, 'g'), ]; return motifs.flatMap((motif) => [...source.matchAll(motif)].map(([trouve]) => trouve.replace(/\s+/g, ' '))); } describe('sources de l’interface : l’état des critères, lu dans la table d’évaluation (§ 5.3, § 13.2, A8)', () => { test('rouge : la garde relève un « sans objet » recalculé, et laisse passer une lecture de l’état', () => { assert.deepEqual(relevesSansObjet('const sansObjet = charge.tables.every((t) => t.objectifs === undefined);'), [ 'sansObjet =', '.tables', '.objectifs', ]); assert.deepEqual(relevesSansObjet('const vide = { coche: true, sansObjet: false };'), ['sansObjet:']); assert.deepEqual(relevesSansObjet('const muet = etat.sansObjet === true; // charge.tables'), []); // Une déstructuration, en déclaration ou en paramètre, et un accès entre // crochets lisent les mêmes objets sans point. const deguise = 'const { tables: lesTables } = charge;\n' + "const aucun = $derived(lesTables.every((t) => (t['objectifs'] ?? []).length === 0));\n" + 'const compte = ({ participants }) => participants.length;'; assert.deepEqual(relevesSansObjet(deguise), ['{ tables: lesTables } =', '{ participants })', "['objectifs']"]); assert.deepEqual(relevesSansObjet("const { table, refus } = evaluation(charge);\nconst etat = etats?.get('tables');"), []); }); test('l’éditeur des priorités, les réglages et leur modèle ne recalculent pas l’état d’un critère', () => { for (const chemin of SOURCES_DE_L_ETAT) { const source = readFileSync(join(RACINE, chemin), 'utf8'); assert.ok(source.includes('etatAffiche'), `${chemin} : l'état ne s'y lit plus, la garde est à revoir`); assert.deepEqual(relevesSansObjet(source), [], chemin); } }); }); describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)', () => { test('aucun littéral de couleur, aucun jeton déclaré hors de jetons.css, le jeton de conflit lu par les seules règles de conflit du plan', () => { exigerVide(releverCouleurs(RACINE), 'couleurs'); }); test('chaque jeton de couleur est défini dans les deux thèmes', () => { exigerVide(releverThemes(RACINE), 'thèmes'); }); test('la garde relève stroke: #c00 et chaque littéral, et pas un jeton ni une adresse de motif', () => { const fichiers = { 'src/interface/jetons.css': JETONS_EPREUVE, 'src/interface/plan/A.svelte': composant({ gabarit: 'x
', style: ' .alerte { color: var(--couleur-conflit-voisins); }', }), 'src/interface/plan/Motif.svelte': composant({ gabarit: '', style: [ ' .trait.voisins-conflit { stroke: var(--couleur-conflit-voisins); }', ' .trait.table-conflit { stroke: var(--couleur-conflit-table); }', ' .trait { fill: var(--couleur-conflit-voisins); }', ' .conflit-voisins { fill: var(--couleur-conflit-table); }', ' .trait.table-conflit { fill: var(--couleur-conflit-voisins); }', ].join('\n'), }), }; assert.deepEqual(avecArbre(fichiers, releverCouleurs), [ "src/interface/accueil/Alerte.svelte:2 jeton de conflit hors d'une règle de conflit du plan, dans le script", "src/interface/accueil/Alerte.svelte:6 jeton de conflit hors d'une règle de conflit du plan, « .alerte »", "src/interface/plan/Motif.svelte:1 jeton de conflit hors d'une règle de conflit du plan, en ligne", "src/interface/plan/Motif.svelte:5 jeton de conflit hors d'une règle de conflit du plan, « .trait »", "src/interface/plan/Motif.svelte:6 jeton de conflit hors d'une règle de conflit du plan, « .conflit-voisins »", "src/interface/plan/Motif.svelte:7 jeton de conflit hors d'une règle de conflit du plan, « .trait.table-conflit »", ]); }); test('la garde des thèmes relève un jeton qu’un thème ne redéfinit pas', () => { const fichiers = { 'src/interface/jetons.css': [ ':root { --espace-1: 1px; }', ':root, :root[data-theme="clair"] { --couleur-sol: #fff; --couleur-gel: #00f; }', ":root[data-theme='sombre'] { --couleur-sol: #000; --couleur-fantome: #0f0; }", '', ].join('\n'), }; assert.deepEqual(avecArbre(fichiers, releverThemes), [ 'src/interface/jetons.css:2 --couleur-gel absent du thème sombre', 'src/interface/jetons.css:3 --couleur-fantome absent du thème clair', ]); }); test('un arbre sans composant ni feuille fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/interface/jetons.css': JETONS_EPREUVE }, releverCouleurs), /couleurs : aucun fichier examiné/); }); test('une feuille des jetons sans jeton de couleur fait échouer la garde des thèmes', () => { const fichiers = { 'src/interface/jetons.css': ':root, :root[data-theme="clair"] { --espace-1: 1px; }\n' }; assert.throws(() => avecArbre(fichiers, releverThemes), /thèmes : aucun jeton de couleur examiné/); }); }); describe('sources de l’interface : balisage et fenêtres du navigateur (§ 13.1, § 2.1)', () => { test('aucun balisage injecté, aucune fenêtre du navigateur, aucun beforeunload, aucun .message affiché', () => { exigerVide(releverBalisageEtFenetres(RACINE), 'balisage et fenêtres'); }); test('la garde relève {@html nom} et chaque forme refusée, et pas leurs voisines ni un commentaire', () => { const fichiers = { 'src/interface/A.svelte': composant({ script: " let nom = $state('');\n // aucun beforeunload, jamais alert(…) ni err.message", gabarit: '{@html nom}
\n{Math.round(liste.length)}
\n{liste.reduce((s, x) => s + x, 0)}
\n{liste.sort().join()}
\n{liste.filter(Boolean).length}
', }), 'src/interface/b.js': 'export const f = (l) => Math.max(...l.sort());\n', 'src/application/evaluation.js': "import { mesurer, manques } from '../moteur/indicateurs.js';\nexport { mesurer };\n", 'src/application/generation.js': [ "import { troisChiffres as chiffres } from '../moteur/indicateurs.js';", "import * as indicateurs from '../moteur/indicateurs.js';", "export { minimumAtteint } from '../moteur/indicateurs.js';", 'export const f = (x) => indicateurs.plafondsRealises(x) + indicateurs.autre(x);', "import { evaluer } from './evaluation.js';", '', ].join('\n'), 'src/application/generation.test.js': "import { mesurer } from '../moteur/indicateurs.js';\n", }; assert.deepEqual(avecArbre(fichiers, releverCablage), [ 'src/application/generation.js:1 import de troisChiffres', 'src/application/generation.js:3 import de minimumAtteint', 'src/application/generation.js:4 import de plafondsRealises', 'src/interface/plan/A.svelte:3 .toSorted(', 'src/interface/plan/A.svelte:6 Math.round', 'src/interface/plan/A.svelte:7 .reduce(', 'src/interface/plan/A.svelte:8 .sort(', ]); }); test('un arbre sans composant fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverCablage), /câblage des composants : aucun fichier examiné/); }); }); describe('sources de l’interface : les sélecteurs élagués (§ 13.3, § 14.2)', () => { test('aucun composant ne porte une règle que le compilateur élague', () => { exigerVide(releverSelecteursElagues(RACINE), 'sélecteurs élagués'); }); test('la garde relève une règle .conflit qui vise un élément sans classe dynamique, et admet class:conflit', () => { const fichiers = { 'src/interface/plan/sous/Table.svelte': composant({ gabarit: '{' '}
", }), 'src/interface/raccourcis.js': "export const R = ['Escape', 'Enter'];\nexport const lire = (e) => e.key === ' ';\n", 'src/interface/A.test.js': "if (e.key === 'Escape') f();\n", 'src/application/c.js': "export const k = (e) => e.key === 'Escape';\n", }; assert.deepEqual(avecArbre(fichiers, releverTouches), [ 'src/interface/A.svelte:2 touche "Enter"', 'src/interface/A.svelte:4 touche "Tab"', 'src/interface/plan/sous/b.js:2 touche "Escape"', 'src/interface/plan/sous/b.js:3 touche " "', 'src/interface/plan/sous/b.js:3 touche "a"', 'src/interface/plan/sous/b.js:4 touche "q"', 'src/interface/plan/sous/b.js:5 touche "ArrowLeft"', 'src/interface/plan/sous/b.js:5 touche "Maj+Tab"', 'src/interface/plan/sous/b.js:7 touche "F2"', ]); }); test('un arbre sans source d’interface, ou réduit à la table des raccourcis, fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverTouches), /touches nommées : aucun fichier examiné/); assert.throws( () => avecArbre({ 'src/interface/raccourcis.js': "export const R = ['Escape'];\n" }, releverTouches), /touches nommées : aucun fichier examiné/, ); }); });