diff --git a/src/application/diagnostic.js b/src/application/diagnostic.js
new file mode 100644
index 0000000..5c4dc44
--- /dev/null
+++ b/src/application/diagnostic.js
@@ -0,0 +1,34 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Le diagnostic de la configuration d'une charge (§ 5.6), pour l'interface,
+// qui n'importe pas le moteur (§ 13.4). Le calcul est celui de diagnostiquer,
+// sur configurationDepuisCharge : ce module n'en refait aucun chiffre.
+//
+// Une charge ouverte peut ne pas se normaliser — une table surréservée, que
+// le stockage admet et que le moteur refuse (CONFIGURATION_INCOHERENTE, A2).
+// L'ErreurConfiguration se rend alors par son code et ses détails, au lieu
+// de lever : le panneau dit le refus, et l'application reste vivante. Toute
+// autre erreur est une faute du code et remonte.
+import { diagnostiquer } from '../moteur/diagnostic.js';
+import { ErreurConfiguration } from '../moteur/erreurs.js';
+import { configurationDepuisCharge } from '../stockage/document.js';
+
+/**
+ * Le diagnostic de la configuration de la charge (§ 5.6). Ne modifie pas la
+ * charge.
+ *
+ * @param {import('../stockage/types.js').Charge} charge
+ * @returns {{diagnostic: import('../moteur/diagnostic.js').Diagnostic|null,
+ * erreur: {code: string, details: Object}|null}}
+ * diagnostic null et erreur, le code et les détails de l'ErreurConfiguration,
+ * quand la configuration ne se normalise pas ; erreur null sinon
+ */
+export function diagnostiquerCharge(charge) {
+ try {
+ return { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null };
+ } catch (erreur) {
+ if (!(erreur instanceof ErreurConfiguration)) throw erreur;
+ return { diagnostic: null, erreur: { code: erreur.code, details: erreur.details } };
+ }
+}
diff --git a/src/application/diagnostic.test.js b/src/application/diagnostic.test.js
new file mode 100644
index 0000000..e40484b
--- /dev/null
+++ b/src/application/diagnostic.test.js
@@ -0,0 +1,83 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Épreuves du diagnostic d'une charge (§ 5.6, § 15.3) : la valeur rendue est
+// celle de diagnostiquer sur la configuration de la charge, sollicitée une
+// seconde fois (§ 19.1) ; une configuration qui ne se normalise pas rend son
+// code et ses détails, sans lever. Les noms d'épreuve sont inventés.
+import assert from 'node:assert/strict';
+import { describe, test } from '../../test/lanceur.js';
+import { texteLivre } from '../demo/livrees.js';
+import { diagnostiquer } from '../moteur/diagnostic.js';
+import { ErreurConfiguration } from '../moteur/erreurs.js';
+import { normaliser } from '../moteur/configuration.js';
+import { serialiser } from '../stockage/canonique.js';
+import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
+import { VERSION } from '../version.genere.js';
+import { diagnostiquerCharge } from './diagnostic.js';
+
+const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
+
+// La petite démonstration où quatre personnes portent une réservation de
+// portée « tous les tours », sans siège, à la table 1, de 3 places.
+function quatreReserveesSurTrois() {
+ const charge = chargeDemo('petite');
+ charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
+ return charge;
+}
+
+describe('diagnostiquerCharge', () => {
+ test('petite démonstration : le diagnostic de sa configuration, erreur null', () => {
+ const charge = chargeDemo('petite');
+ const rendu = diagnostiquerCharge(charge);
+ assert.deepEqual(rendu, { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null });
+ assert.ok(rendu.diagnostic.plafondAPriori.repartition.length > 0);
+ });
+
+ test('petite démonstration au conflit inévitable : le plancher du groupe de 5', () => {
+ const charge = chargeDemo('petite-conflit');
+ const { diagnostic, erreur } = diagnostiquerCharge(charge);
+ assert.equal(erreur, null);
+ assert.equal(diagnostic.collisions.length, 1);
+ assert.deepEqual(diagnostic, diagnostiquer(configurationDepuisCharge(charge)));
+ });
+
+ test('quatre réservées sur une table de 3 : analyser admet, normaliser refuse ; le code rendu, sans lever', () => {
+ const charge = quatreReserveesSurTrois();
+ // Le fichier qui la porte se relit : la charge est admise par le stockage.
+ const relue = analyser(serialiser(charge, { revision: 1, produitVersion: VERSION.affichee })).charge;
+ assert.equal(relue.reservations.length, 4);
+ let attendue = null;
+ try {
+ normaliser(configurationDepuisCharge(relue));
+ } catch (erreur) {
+ assert.ok(erreur instanceof ErreurConfiguration);
+ attendue = erreur;
+ }
+ assert.notEqual(attendue, null);
+ const rendu = diagnostiquerCharge(relue);
+ assert.deepEqual(rendu, { diagnostic: null, erreur: { code: 'SURRESERVATION', details: attendue.details } });
+ assert.equal(rendu.erreur.code, attendue.code);
+ });
+
+ test('un événement neuf, sans participant ni table : un diagnostic, aucune erreur', () => {
+ const charge = creerCharge({ id: 'evt-neuf', nom: 'Club des Merles', siegesParDefaut: 8, tours: 4 });
+ const { diagnostic, erreur } = diagnostiquerCharge(charge);
+ assert.equal(erreur, null);
+ assert.deepEqual(diagnostic, diagnostiquer(configurationDepuisCharge(charge)));
+ });
+
+ test('une erreur qui n\u{2019}est pas une ErreurConfiguration remonte', () => {
+ const charge = chargeDemo('petite');
+ charge.tables = null;
+ assert.throws(() => diagnostiquerCharge(charge), TypeError);
+ });
+
+ test('la charge reçue ne change pas', () => {
+ const charge = quatreReserveesSurTrois();
+ const avant = structuredClone(charge);
+ diagnostiquerCharge(charge);
+ diagnostiquerCharge(chargeDemo('grande'));
+ assert.deepEqual(charge, avant);
+ });
+});
diff --git a/src/interface/diagnostic/Diagnostic.navigateur.test.js b/src/interface/diagnostic/Diagnostic.navigateur.test.js
new file mode 100644
index 0000000..d3ad40e
--- /dev/null
+++ b/src/interface/diagnostic/Diagnostic.navigateur.test.js
@@ -0,0 +1,364 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Le diagnostic dans un vrai moteur de rendu (§ 2.1 étape 8, § 5.4, § 5.5,
+// § 5.6, § 7.5, § 8.4, § 15.1, § 15.3, § 15.4, § 19.3 capture 10) : le
+// panneau compact du plan, lisible dès l'ouverture sans aucun clic ; la
+// section complète, vivante en lecture et sur un plan bloqué ; le remède qui
+// désactive la séparation, exécuté en écriture et refusé en lecture ; une
+// configuration que le moteur refuse ; le bandeau du plan qui énonce le
+// diagnostic quand toutes les tables sont en conflit. Chaque épreuve monte
+// App par monterApp, au cadre de 1 366 × 700, et lit le rendu : le texte,
+// la boîte, les attributs data-… — jamais une classe.
+//
+// Un énoncé porte data-enonce (sa clé), son texte data-phrase, une
+// appartenance data-donnee, un remède data-remede. La valeur attendue vient
+// de diagnostiquer, sollicité une seconde fois sur la configuration de la
+// charge ouverte (§ 19.1) ; le texte, de la même traduction que celle de
+// l'application. Les noms d'épreuve sont inventés.
+import { afterEach, beforeEach, describe, expect, test } from 'vitest';
+import { flushSync } from 'svelte';
+import { TABLES_APPLICATION } from '../../application/libelles.js';
+import { creerTraduction } from '../../application/traduction.js';
+import { texteLivre } from '../../demo/livrees.js';
+import { diagnostiquer } from '../../moteur/diagnostic.js';
+import { ErreurConfiguration } from '../../moteur/erreurs.js';
+import { normaliser } from '../../moteur/configuration.js';
+import { serialiser } from '../../stockage/canonique.js';
+import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
+import { SUFFIXES } from '../../stockage/noms.js';
+import { VERSION } from '../../version.genere.js';
+import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw';
+import { fixerCadre } from '../../../test/navigateur/entrees.js';
+import { monterApp } from '../../../test/navigateur/montage.js';
+import { visible } from '../../../test/navigateur/rendu.js';
+import { TABLES_INTERFACE } from '../libelles.js';
+import { enonces, numeroDeCharge } from './phrases.js';
+
+const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
+const V1 = VERSION.affichee;
+
+// --- Les charges d'épreuve -------------------------------------------------------
+
+const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
+
+// Douze personnes inventées, quatre tables de 3, quatre tours ; appartenance
+// donne celle de la personne de rang p.
+function chargeDouze(appartenance) {
+ const charge = creerCharge({ id: 'evt-diagnostic', nom: 'Soirée des Merles', siegesParDefaut: 3, tours: 4 });
+ charge.participants = Array.from({ length: 12 }, (_, p) => ({
+ id: p + 1,
+ nom: `Pervenche${p + 1}`,
+ prenom: 'Théo',
+ appartenance: appartenance(p),
+ courriel: null,
+ titrePressenti: null,
+ notes: null,
+ exclu: false,
+ }));
+ charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
+ charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
+ return charge;
+}
+
+// La petite démonstration où quatre personnes portent une réservation de
+// portée « tous les tours », sans siège, à la table 1, de 3 places : le
+// stockage l'admet, le moteur la refuse (SURRESERVATION).
+function quatreReserveesSurTrois() {
+ const charge = chargeDemo('petite');
+ charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
+ return charge;
+}
+
+// --- Le montage --------------------------------------------------------------------
+
+let montes = [];
+beforeEach(async () => {
+ await fixerCadre(1366, 700);
+});
+afterEach(() => {
+ const erreurs = montes.flatMap((m) => m.erreurs());
+ for (const m of montes.reverse()) m.demonter();
+ montes = [];
+ window.scrollTo(0, 0);
+ expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
+});
+
+// Attend que l'assertion passe, le rendu de Svelte à jour.
+async function attendre(assertion) {
+ for (let essai = 0; ; essai += 1) {
+ flushSync();
+ try {
+ return assertion();
+ } catch (erreur) {
+ if (essai >= 200) throw erreur;
+ await new Promise((resoudre) => requestAnimationFrame(resoudre));
+ }
+ }
+}
+
+async function monter(options) {
+ const m = await monterApp(options);
+ montes.push(m);
+ return m;
+}
+
+// La démonstration de clé donnée, chargée par la façade et ouverte en
+// lecture, sur la section qu'ouvre un événement.
+async function demonstration(cle) {
+ const m = await monter();
+ await m.facade.chargerDemonstration(cle);
+ flushSync();
+ return m;
+}
+
+// La charge posée en fichier d'état sous base, ouverte en lecture.
+async function ouvrirTexte(texte, base = 'soiree') {
+ const m = await monter({ fichiers: [{ racine: 'documents', chemin: `${base}${SUFFIXES.etat}`, texte }] });
+ await m.seance.ouvrir(base);
+ flushSync();
+ return m;
+}
+
+const ouvrirCharge = (charge) => ouvrirTexte(serialiser(charge, { revision: 1, produitVersion: V1 }));
+
+// --- Les lectures du rendu ---------------------------------------------------------
+
+const dans = (m, selecteur) => m.racine.querySelector(selecteur);
+const section = (m) => dans(m, '[data-section="diagnostic"]');
+const panneau = (m) => dans(m, '[data-panneau-diagnostic]');
+const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
+const charge = (m) => m.seance.etat().charge;
+const revision = (m) => m.seance.etat().evenement.revision;
+
+// Chaque énoncé rendu sous la racine : sa clé, son appartenance, son texte,
+// et son remède, null quand il n'en porte pas.
+function lus(racine) {
+ return [...racine.querySelectorAll('[data-enonce]')].map((li) => {
+ const donnee = li.querySelector('[data-donnee]');
+ const remede = li.querySelector('[data-remede]');
+ return {
+ cle: li.dataset.enonce,
+ groupe: donnee === null ? null : texteDe(donnee),
+ phrase: texteDe(li.querySelector('[data-phrase]')),
+ remede: remede === null ? null : texteDe(remede),
+ };
+ });
+}
+
+// Les énoncés attendus pour la charge, du moteur sollicité une seconde fois ;
+// avecRemede : la forme complète, qui écrit le texte du remède.
+function attendus(chargeLue, { avecRemede }) {
+ const diagnostic = diagnostiquer(configurationDepuisCharge(chargeLue));
+ const liste = enonces(diagnostic, { numeroDe: numeroDeCharge(chargeLue), tours: chargeLue.evenement.tours });
+ return liste.map(({ cle, details, remede }) => ({
+ cle,
+ groupe: details.groupe ?? null,
+ phrase: t(cle, details),
+ remede: avecRemede && remede !== null ? t(remede.cle, remede.details) : null,
+ }));
+}
+
+// --- Les épreuves ------------------------------------------------------------------
+
+describe('le diagnostic se lit sans rien demander (§ 2.1 étape 8, § 5.6)', () => {
+ test('la petite au conflit inévitable, sans proposition, ouverte : le panneau du plan l\u{2019}énonce, dans la fenêtre ; une table ajoutée le change sans autre geste', async () => {
+ const m = await demonstration('petite-conflit');
+ expect(m.ui.etat().section).toBe('plan');
+ expect(charge(m).propositions.length).toBe(0);
+ const avant = attendus(charge(m), { avecRemede: false });
+ expect(avant.length).toBeGreaterThan(0);
+ expect(avant.map(({ cle }) => cle)).toContain('diagnostic.collisions');
+ await attendre(() => expect(lus(panneau(m))).toEqual(avant));
+ const premier = panneau(m).querySelector('[data-enonce]');
+ expect(visible(premier)).toBe(true);
+ expect(visible(premier.querySelector('[data-phrase]'))).toBe(true);
+ // Compact : aucun remède, aucune commande dans le panneau.
+ expect(panneau(m).querySelectorAll('[data-remede], [data-commande]').length).toBe(0);
+
+ await m.seance.passerEnEcriture();
+ await m.seance.executer('creerTable', { forme: 'ronde' });
+ const apres = attendus(charge(m), { avecRemede: false });
+ expect(apres).not.toEqual(avant);
+ expect(apres.map(({ cle }) => cle)).not.toContain('diagnostic.collisions');
+ await attendre(() => expect(lus(panneau(m))).toEqual(apres));
+ });
+});
+
+describe('vivant en lecture (§ 8.4)', () => {
+ test('en lecture, la section se lit, remèdes compris, sans rien écrire ; sur un plan bloqué, de même', async () => {
+ const m = await demonstration('petite-conflit');
+ const ecrituresAvant = m.trace.length;
+ m.ui.allerA('diagnostic');
+ flushSync();
+ expect(m.seance.etat().mode).toBe('lecture');
+ const attendue = attendus(charge(m), { avecRemede: true });
+ expect(attendue.filter(({ remede }) => remede !== null).length).toBeGreaterThan(0);
+ await attendre(() => expect(lus(section(m))).toEqual(attendue));
+ expect(visible(section(m).querySelector('[data-phrase]'))).toBe(true);
+ expect(m.trace.length).toBe(ecrituresAvant);
+
+ await m.seance.passerEnEcriture();
+ await m.seance.executer('changerEtat', { etat: 'bloque' });
+ expect(m.seance.etat().refus.code).toBe('LECTURE_SEULE');
+ m.ui.allerA('diagnostic');
+ await attendre(() => expect(lus(section(m))).toEqual(attendus(charge(m), { avecRemede: true })));
+ m.ui.allerA('plan');
+ await attendre(() => expect(lus(panneau(m))).toEqual(attendus(charge(m), { avecRemede: false })));
+ });
+});
+
+describe('la grande démonstration (§ 15.1, § 19.3 capture 10)', () => {
+ test('les valeurs affichées égalent celles de diagnostiquer : 24, 99, le plancher de l\u{2019}écart d\u{2019}itinéraire', async () => {
+ const m = await demonstration('grande');
+ m.ui.allerA('diagnostic');
+ const diagnostic = diagnostiquer(configurationDepuisCharge(charge(m)));
+ expect(diagnostic.plafondAPriori.repartition[0]).toEqual({ valeur: 24, ancres: 4, mobiles: 0 });
+ expect(diagnostic.retoursImposes).toBe(99);
+ expect(diagnostic.ecartItineraire).not.toBeNull();
+ const attendue = attendus(charge(m), { avecRemede: true });
+ await attendre(() => expect(lus(section(m))).toEqual(attendue));
+ const phrases = lus(section(m)).map(({ phrase }) => phrase);
+ expect(phrases).toContain(t('diagnostic.plafond', diagnostic.plafondAPriori.repartition[0]));
+ expect(phrases).toContain(
+ t('diagnostic.imposes', { retoursImposes: diagnostic.retoursImposes, ancragesImposantRetours: diagnostic.ancragesImposantRetours }),
+ );
+ expect(phrases).toContain(t('diagnostic.ecartItineraire', diagnostic.ecartItineraire));
+ expect(phrases.join(' ')).toMatch(/\b24 rencontres\b/);
+ expect(phrases.join(' ')).toMatch(/\b99 retours imposés\b/);
+ expect(phrases.join(' ')).toMatch(new RegExp(`plancher ${diagnostic.ecartItineraire.plancher},`));
+ });
+});
+
+describe('une seule appartenance (§ 15.4)', () => {
+ test('la phrase et son remède ; en lecture, refusé avec « Modifier » ; en écriture, exécuté en une entrée', async () => {
+ const m = await ouvrirCharge(chargeDouze(() => 'Club des Merles'));
+ m.ui.allerA('diagnostic');
+ flushSync();
+ const [premier] = lus(section(m));
+ expect(premier).toEqual({
+ cle: 'diagnostic.uneSeuleAppartenance',
+ groupe: null,
+ phrase: t('diagnostic.uneSeuleAppartenance'),
+ remede: t('diagnostic.remede.separation'),
+ });
+ const ligne = section(m).querySelector('[data-enonce="diagnostic.uneSeuleAppartenance"]');
+ const bouton = ligne.querySelector('[data-commande="modifierReglage"]');
+ expect(bouton).not.toBeNull();
+ expect(texteDe(bouton)).toBe(t('communs.remede.modifierReglage'));
+ expect(visible(bouton)).toBe(true);
+
+ // En lecture : refusé, le refus posé à côté, avec « Modifier » ; rien d'écrit.
+ const ecrituresAvant = m.trace.length;
+ const revisionAvant = revision(m);
+ bouton.click();
+ const refus = await attendre(() => {
+ const trouve = ligne.querySelector('[data-refus="MODE_LECTURE"]');
+ expect(trouve).not.toBeNull();
+ return trouve;
+ });
+ const modifier = refus.querySelector('[data-commande="modifier"]');
+ expect(modifier).not.toBeNull();
+ expect(texteDe(modifier)).toBe(t('communs.remede.modifier'));
+ expect(revision(m)).toBe(revisionAvant);
+ expect(m.trace.length).toBe(ecrituresAvant);
+
+ // « Modifier » passe en écriture ; le remède s'exécute, une entrée.
+ modifier.click();
+ await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
+ const revisionEcriture = revision(m);
+ ligne.querySelector('[data-commande="modifierReglage"]').click();
+ await attendre(() => expect(charge(m).reglages.separerAppartenances).toBe(false));
+ expect(revision(m)).toBe(revisionEcriture + 1);
+ await attendre(() => expect(section(m).querySelector('[data-enonce="diagnostic.uneSeuleAppartenance"]')).toBeNull());
+ });
+});
+
+describe('la configuration refusée (§ 5.6, § 5.9)', () => {
+ test('quatre réservées sur une table de 3 : le code traduit, aucun énoncé, aucun message brut ; les autres sections vivent', async () => {
+ const chargeRefusee = quatreReserveesSurTrois();
+ let refusMoteur = null;
+ try {
+ normaliser(configurationDepuisCharge(chargeRefusee));
+ } catch (erreur) {
+ expect(erreur).toBeInstanceOf(ErreurConfiguration);
+ refusMoteur = erreur;
+ }
+ expect(refusMoteur?.code).toBe('SURRESERVATION');
+ const m = await ouvrirCharge(chargeRefusee);
+ const message = t('SURRESERVATION', refusMoteur.details);
+
+ // Le panneau du plan, ouvert d'emblée.
+ await attendre(() => expect(panneau(m).querySelector('[data-refus="SURRESERVATION"]')).not.toBeNull());
+ for (const racine of [panneau(m)]) {
+ expect(texteDe(racine.querySelector('[data-refus="SURRESERVATION"]'))).toBe(message);
+ expect(racine.querySelectorAll('[data-enonce]').length).toBe(0);
+ expect(racine.textContent).not.toContain(refusMoteur.message);
+ }
+
+ // La section.
+ m.ui.allerA('diagnostic');
+ await attendre(() => expect(section(m)).not.toBeNull());
+ const porteur = section(m).querySelector('[data-refus="SURRESERVATION"]');
+ expect(texteDe(porteur)).toBe(message);
+ expect(visible(porteur)).toBe(true);
+ expect(texteDe(section(m))).toContain(t('diagnostic.nonCalcule'));
+ expect(section(m).querySelectorAll('[data-enonce]').length).toBe(0);
+ expect(section(m).textContent).not.toContain(refusMoteur.message);
+
+ // Les participants se lisent toujours.
+ m.ui.allerA('participants');
+ await attendre(() => expect(m.racine.querySelectorAll('[data-section="participants"] [data-participant]').length).toBe(12));
+ });
+});
+
+describe('le bandeau du plan énonce le diagnostic (§ 7.5)', () => {
+ // Les entrées attendues du bandeau, du moteur sollicité une seconde fois :
+ // la phrase, collisionPartout, puis chaque plancher de groupe.
+ function bandeauAttendu(chargeLue) {
+ const diagnostic = diagnostiquer(configurationDepuisCharge(chargeLue));
+ expect(diagnostic.collisionPartout).toBe(true);
+ expect(diagnostic.collisions.length).toBe(2);
+ return [
+ t('plan.bandeau.toutesEnConflit'),
+ t('plan.bandeau.collisionPartout'),
+ ...diagnostic.collisions.map(
+ ({ groupe, effectif, plancherCumulees }) => `${groupe} ${t('plan.bandeau.plancherGroupe', { groupe, effectif, plancherCumulees })}`,
+ ),
+ ];
+ }
+
+ const bandeau = (m) => dans(m, '[data-bandeau-plan]');
+ // Chaque entrée, ses nœuds — l'appartenance, puis le texte — joints par
+ // une espace.
+ const entrees = (m) => [...bandeau(m).querySelectorAll('li')].map((li) => [...li.children].map(texteDe).join(' '));
+
+ test('toutes en conflit, la proposition montrée : la phrase, collisionPartout, puis les planchers de chaque groupe', async () => {
+ const m = await ouvrirTexte(TEXTE_TOUTES_EN_CONFLIT, 'toutes_en_conflit');
+ m.ui.montrerProposition(1);
+ const attendu = bandeauAttendu(charge(m));
+ await attendre(() => expect(bandeau(m)).not.toBeNull());
+ await attendre(() => expect(entrees(m)).toEqual(attendu));
+ expect(visible(bandeau(m).querySelector('li'))).toBe(true);
+ // Les chiffres du moteur, au chiffre près, dans le rendu.
+ const { collisions } = diagnostiquer(configurationDepuisCharge(charge(m)));
+ for (const { groupe, plancherCumulees } of collisions) {
+ const ligne = [...bandeau(m).querySelectorAll('li')].find((li) => li.textContent.startsWith(groupe));
+ expect(ligne.textContent).toContain(`${plancherCumulees} collisions`);
+ }
+ });
+
+ test('une table rendue saine au tour montré : ni phrase ni plancher', async () => {
+ // La personne 8, à la table 1 du tour 1 avec 4 et 7, prend une troisième
+ // appartenance : la table 1 réunit trois appartenances à ce tour.
+ const lue = analyser(TEXTE_TOUTES_EN_CONFLIT).charge;
+ lue.participants.find(({ id }) => id === 8).appartenance = 'Grisaille';
+ expect(lue.propositions[0].placement[0].sieges[0]).toEqual([4, 7, 8]);
+ const m = await ouvrirCharge(lue);
+ m.ui.montrerProposition(1);
+ await attendre(() => expect(m.racine.querySelectorAll('[data-plan] [data-table]').length).toBe(4));
+ await attendre(() => expect(bandeau(m)).toBeNull());
+ expect(m.racine.textContent).not.toContain(t('plan.bandeau.toutesEnConflit'));
+ expect(m.racine.querySelector('[data-surface]').textContent).not.toContain(t('plan.bandeau.collisionPartout'));
+ });
+});
diff --git a/src/interface/diagnostic/Diagnostic.svelte b/src/interface/diagnostic/Diagnostic.svelte
index c1984d3..0db3b4d 100644
--- a/src/interface/diagnostic/Diagnostic.svelte
+++ b/src/interface/diagnostic/Diagnostic.svelte
@@ -2,12 +2,149 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
- {t('coquille.section.diagnostic')}
-
+{#snippet contenu()}
+ {#if resultat !== null && resultat.erreur !== null}
+
{t('diagnostic.nonCalcule')}
+
+ {:else if liste.length > 0}
+
+ {#each liste as enonce, rang (rang)}
+ -
+
+ {#if enonce.details.groupe !== undefined}{enonce.details.groupe}{/if}{t(enonce.cle, enonce.details)}
+
+ {#if !compact && enonce.remede !== null}
+ {@const action = actionDe(enonce.remede)}
+ {t(enonce.remede.cle, enonce.remede.details)}
+ {#if action !== null}
+
+ {/if}
+ {/if}
+
+ {/each}
+
+ {/if}
+{/snippet}
+
+{#if compact}
+
+
{t('coquille.section.diagnostic')}
+ {@render contenu()}
+
+{:else}
+
+ {t('coquille.section.diagnostic')}
+ {@render contenu()}
+
+{/if}
+
+
diff --git a/src/interface/diagnostic/phrases.js b/src/interface/diagnostic/phrases.js
new file mode 100644
index 0000000..fde613d
--- /dev/null
+++ b/src/interface/diagnostic/phrases.js
@@ -0,0 +1,192 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Les énoncés du diagnostic (§ 5.6) : ce que la configuration rend
+// inévitable, combien, et ce qui le rendrait évitable. Chaque énoncé est
+// {cle, details, remede}, que l'écran traduit ; ses détails sont les
+// chiffres du diagnostic tels que le moteur les rend — aucun ne se calcule
+// ici —, plus le numéro affiché d'une table, que donne numeroDe, et le
+// nombre de tours de l'événement, une donnée de la charge.
+//
+// remede : null, quand l'énoncé ne dit qu'une absence ; {cle, details}, le
+// texte de ce qui lèverait l'inévitable ; {cle, details, commande,
+// arguments} quand une commande le réalise sans rien détruire, exécutable
+// telle quelle par seance.executer, comme le remède d'un refus. Retirer des
+// tours détruit les réservations d'un tour retiré : le remède en tours reste
+// un texte.
+//
+// L'ordre est celui du § 5.6 — collisions et excédent, redondance,
+// animateurs, ancrage, écart d'itinéraire, manque de places —, précédé de ce
+// qui vide la séparation de son sens (§ 15.4, § 7.5) et suivi du plafond a
+// priori (§ 5.5) et des retours imposés (§ 5.4). Un plancher ne s'énonce que
+// strictement positif : un plancher nul laisserait croire qu'un plancher a
+// été calculé là où il n'y en a pas.
+
+// Désactiver la séparation : le seul remède que réalise une commande. Un
+// objet neuf à chaque appel.
+const separer = () => ({
+ cle: 'diagnostic.remede.separation',
+ details: {},
+ commande: 'modifierReglage',
+ arguments: { cle: 'separerAppartenances', valeur: false },
+});
+
+// Un remède neuf de la clé donnée.
+const remede = (cle, details = {}) => ({ cle, details });
+
+// La séparation des appartenances vidée de son sens : une seule
+// appartenance, ou une collision forcée à chaque table de chaque tour.
+function separation({ uneSeuleAppartenance, collisionPartout }) {
+ const liste = [];
+ if (uneSeuleAppartenance) liste.push({ cle: 'diagnostic.uneSeuleAppartenance', details: {}, remede: separer() });
+ if (collisionPartout) liste.push({ cle: 'diagnostic.collisionPartout', details: {}, remede: separer() });
+ return liste;
+}
+
+// Les tables à ajouter que rend le moteur, ou, quand aucune ne suffit, la
+// redistribution de l'appartenance.
+const remedeCollisions = ({ tablesManquantes }) =>
+ tablesManquantes === null ? remede('diagnostic.remede.redistribuer') : remede('diagnostic.remede.tables', { tablesManquantes });
+
+// Chaque groupe en collision : son plancher de collisions cumulées et de
+// paires distinctes, puis son excédent quand il est strictement positif.
+function collisions(diagnostic, tours) {
+ return diagnostic.collisions.flatMap((ligne) => {
+ const { groupe, effectif, plancherCumulees, plancherPairesDistinctes, plancherExcedent } = ligne;
+ const liste = [
+ {
+ cle: 'diagnostic.collisions',
+ details: { groupe, effectif, plancherCumulees, plancherPairesDistinctes, tours },
+ remede: remedeCollisions(ligne),
+ },
+ ];
+ if (plancherExcedent !== null) {
+ liste.push({ cle: 'diagnostic.excedent', details: { groupe, plancherExcedent }, remede: remedeCollisions(ligne) });
+ }
+ return liste;
+ });
+}
+
+// La redondance minimale, quand elle est positive, et le nombre de tours qui
+// l'annule ; sans lui, ce qui la fait tenir.
+function redondance({ redondanceMinimale, remedes }, tours) {
+ if (redondanceMinimale === null || redondanceMinimale === 0) return [];
+ const { toursSansRedondance } = remedes;
+ return [
+ {
+ cle: 'diagnostic.redondance',
+ details: { redondanceMinimale },
+ remede:
+ toursSansRedondance === null
+ ? remede('diagnostic.remede.appartenances')
+ : remede('diagnostic.remede.tours', { toursSansRedondance, tours }),
+ },
+ ];
+}
+
+// Les animateurs d'une même appartenance, quand ils imposent au moins une
+// redondance.
+function animateurs({ animateursMemeAppartenance, redondanceImposeeParAnimateurs }) {
+ if (!animateursMemeAppartenance || redondanceImposeeParAnimateurs === 0) return [];
+ return [
+ {
+ cle: 'diagnostic.animateurs',
+ details: { redondanceImposeeParAnimateurs },
+ remede: remede('diagnostic.remede.animateurs'),
+ },
+ ];
+}
+
+// Chaque table gelée ou à moitié ancrée, dans l'ordre du diagnostic,
+// désignée par son numéro affiché.
+function ancrage(diagnostic, numeroDe) {
+ return diagnostic.ancrage
+ .filter(({ gelee, moitie }) => gelee || moitie)
+ .map(({ table, ancres, capacite, gelee }) => ({
+ cle: gelee ? 'diagnostic.ancrage.gelee' : 'diagnostic.ancrage.moitie',
+ details: { numero: numeroDe(table), ancres, capacite },
+ remede: remede('diagnostic.remede.ancrage'),
+ }));
+}
+
+// Le plancher de l'écart d'itinéraire maximal, là où le moteur le prouve.
+function ecartItineraire({ ecartItineraire: ecart }) {
+ if (ecart === null) return [];
+ return [
+ {
+ cle: 'diagnostic.ecartItineraire',
+ details: { plancher: ecart.plancher, mobilesAuMoins: ecart.mobilesAuMoins },
+ remede: remede('diagnostic.remede.ecartItineraire'),
+ },
+ ];
+}
+
+// Le manque de places, quand il est positif.
+function placesManquantes({ placesManquantes: manquantes }) {
+ if (manquantes === 0) return [];
+ return [{ cle: 'diagnostic.placesManquantes', details: { placesManquantes: manquantes }, remede: remede('diagnostic.remede.places') }];
+}
+
+// Le plafond a priori, une ligne par valeur de la répartition ; une
+// population vide n'en a aucun, et l'énoncé le dit par le tiret.
+function plafond({ plafondAPriori }) {
+ if (plafondAPriori.repartition.length === 0) return [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }];
+ return plafondAPriori.repartition.map(({ valeur, ancres, mobiles }) => ({
+ cle: 'diagnostic.plafond',
+ details: { valeur, ancres, mobiles },
+ remede: remede('diagnostic.remede.plafond'),
+ }));
+}
+
+// Les retours que les réservations imposent, à part des retours choisis.
+function imposes({ retoursImposes, ancragesImposantRetours }) {
+ if (retoursImposes === 0) return [];
+ return [
+ {
+ cle: 'diagnostic.imposes',
+ details: { retoursImposes, ancragesImposantRetours },
+ remede: remede('diagnostic.remede.imposes'),
+ },
+ ];
+}
+
+/**
+ * Les énoncés d'un diagnostic, dans l'ordre décrit en tête. Ne modifie pas
+ * le diagnostic.
+ *
+ * @param {Object} diagnostic celui que rend diagnostiquerCharge
+ * @param {{numeroDe: (table: number) => number, tours: number}} options
+ * numeroDe : le numéro affiché d'un identifiant de table ; tours : le
+ * nombre de tours de l'événement
+ * @returns {Array<{cle: string, details: Object, remede: null|{cle: string, details: Object,
+ * commande?: string, arguments?: Object}}>}
+ */
+export function enonces(diagnostic, { numeroDe, tours }) {
+ return [
+ ...separation(diagnostic),
+ ...collisions(diagnostic, tours),
+ ...redondance(diagnostic, tours),
+ ...animateurs(diagnostic),
+ ...ancrage(diagnostic, numeroDe),
+ ...ecartItineraire(diagnostic),
+ ...placesManquantes(diagnostic),
+ ...plafond(diagnostic),
+ ...imposes(diagnostic),
+ ];
+}
+
+/**
+ * Le numéro affiché de chaque table de la charge, par identifiant.
+ *
+ * @param {{tables: Array<{id: number, numero: number}>}} charge
+ * @returns {(table: number) => number} lève RangeError pour un identifiant
+ * que la charge n'a pas
+ */
+export function numeroDeCharge(charge) {
+ const numeros = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
+ return (table) => {
+ const numero = numeros.get(table);
+ if (numero === undefined) throw new RangeError(`table inconnue : ${JSON.stringify(table)}`);
+ return numero;
+ };
+}
diff --git a/src/interface/diagnostic/phrases.test.js b/src/interface/diagnostic/phrases.test.js
new file mode 100644
index 0000000..3b1897e
--- /dev/null
+++ b/src/interface/diagnostic/phrases.test.js
@@ -0,0 +1,494 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Épreuves des énoncés du diagnostic (§ 5.4, § 5.5, § 5.6, § 12.10.4,
+// § 15.1, § 15.3, § 15.4) : chaque énoncé porte les chiffres que rend
+// diagnostiquer, sollicité une seconde fois (§ 19.1), et la clé de son
+// remède ; le texte se lit par la même traduction que celle de
+// l'application. Les charges partent des démonstrations livrées ou se
+// construisent ici, sur des noms inventés.
+import assert from 'node:assert/strict';
+import { describe, test } from '../../../test/lanceur.js';
+import { diagnostiquerCharge } from '../../application/diagnostic.js';
+import { TABLES_APPLICATION } from '../../application/libelles.js';
+import { creerTraduction } from '../../application/traduction.js';
+import { texteLivre } from '../../demo/livrees.js';
+import { diagnostiquer } from '../../moteur/diagnostic.js';
+import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
+import { TABLES_INTERFACE } from '../libelles.js';
+import { enonces, numeroDeCharge } from './phrases.js';
+
+const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
+const TIRET = '\u{2014}';
+
+// --- Les charges d'épreuve ---------------------------------------------------------
+
+const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
+
+/**
+ * Une charge construite : une personne par appartenance donnée (null : sans
+ * appartenance), d'identifiants 1 à N ; une table par capacité, d'identifiant
+ * 1 à T et de numéro numeros[rang] (rang + 1 par défaut) ; les réservations
+ * de portée « tous les tours », sans siège, données en [participant, table].
+ */
+function chargeDe({ appartenances, capacites, tours = 4, ancres = [], separer = true, numeros = null }) {
+ const charge = creerCharge({ id: 'evt-diagnostic', nom: 'Soirée des Merles', siegesParDefaut: 3, tours });
+ charge.participants = appartenances.map((appartenance, rang) => ({
+ id: rang + 1,
+ nom: `Ombrelle${rang + 1}`,
+ prenom: 'Iris',
+ appartenance,
+ courriel: null,
+ titrePressenti: null,
+ notes: null,
+ exclu: false,
+ }));
+ charge.tables = capacites.map((sieges, rang) => ({
+ id: rang + 1,
+ numero: numeros === null ? rang + 1 : numeros[rang],
+ sieges,
+ forme: 'ronde',
+ position: { x: 300 * rang, y: 0 },
+ }));
+ charge.reservations = ancres.map(([participant, table]) => ({ participant, table, siege: null, portee: 'tous', tour: null }));
+ charge.reglages.separerAppartenances = separer;
+ charge.prochainsIds = { participant: appartenances.length + 1, table: capacites.length + 1, proposition: 1 };
+ return charge;
+}
+
+const fois = (n, valeur) => Array(n).fill(valeur);
+
+// Le diagnostic d'une charge, par l'application, et ses énoncés.
+function lire(charge) {
+ const { diagnostic, erreur } = diagnostiquerCharge(charge);
+ assert.equal(erreur, null);
+ return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }) };
+}
+
+const cles = (liste) => liste.map(({ cle }) => cle);
+const avecCle = (liste, cle) => liste.filter((enonce) => enonce.cle === cle);
+const texte = ({ cle, details }) => t(cle, details);
+
+// Les configurations nommées de l'épreuve.
+const CONFIGURATIONS = {
+ // 28 personnes en 18 appartenances, dix de 2 et huit de 1, quatre tables
+ // de 7, quatre tours : la redondance du remède en tours (M1).
+ redondance: () =>
+ chargeDe({
+ appartenances: [
+ ...Array.from({ length: 10 }, (_, g) => fois(2, `Cercle ${g + 1}`)).flat(),
+ ...Array.from({ length: 8 }, (_, g) => `Atelier ${g + 1}`),
+ ],
+ capacites: fois(4, 7),
+ }),
+ // Quatre tables de 3, quatre tours, un animateur par table, tous du Club
+ // des Merles ; les huit autres sans appartenance.
+ animateurs: () =>
+ chargeDe({
+ appartenances: [...fois(4, 'Club des Merles'), ...fois(8, null)],
+ capacites: fois(4, 3),
+ ancres: [[1, 1], [2, 2], [3, 3], [4, 4]],
+ }),
+ // Table 1, numérotée 7 : 3 places, 3 ancrés ; table 2, numérotée 8 :
+ // 4 places, 2 ancrés ; table 3, numérotée 9 : 4 places, aucun.
+ ancrage: () =>
+ chargeDe({
+ appartenances: fois(10, null),
+ capacites: [3, 4, 4],
+ numeros: [7, 8, 9],
+ ancres: [[1, 1], [2, 1], [3, 1], [4, 2], [5, 2]],
+ }),
+ // Trois membres d'une appartenance, deux tables de 2, quatre tours.
+ excedent: () => chargeDe({ appartenances: fois(3, 'Grisaille'), capacites: [2, 2] }),
+ // Deux membres réservés à la même table : aucune table ajoutée ne les
+ // sépare.
+ memeTable: () =>
+ chargeDe({ appartenances: [...fois(2, 'Grisaille'), ...fois(4, null)], capacites: [3, 3], ancres: [[1, 1], [2, 1]] }),
+ // Treize personnes, quatre tables de 3.
+ manque: () => chargeDe({ appartenances: fois(13, null), capacites: fois(4, 3) }),
+ // Douze personnes en deux appartenances de six, quatre tables de 3.
+ toutesEnConflit: () =>
+ chargeDe({
+ appartenances: Array.from({ length: 12 }, (_, p) => (p % 2 === 0 ? 'Club des Merles' : 'Cercle des Hérons')),
+ capacites: fois(4, 3),
+ }),
+ // Douze personnes d'une seule appartenance, séparation active.
+ uneSeule: () => chargeDe({ appartenances: fois(12, 'Club des Merles'), capacites: fois(4, 3) }),
+ // Huit personnes en deux appartenances de quatre, une table de 8, un tour.
+ unTour: () => chargeDe({ appartenances: [...fois(4, 'Grisaille'), ...fois(4, 'Club des Merles')], capacites: [8], tours: 1 }),
+ petite: () => chargeDemo('petite'),
+ petiteConflit: () => chargeDemo('petite-conflit'),
+ grande: () => chargeDemo('grande'),
+ grandeSansException: () => chargeDemo('grande-sans-exception'),
+};
+
+// --- Chaque champ, son énoncé ------------------------------------------------------
+
+// Pour chaque champ du diagnostic : ce qui le rend non nul, et les énoncés
+// qu'il appelle alors — la clé et les détails exacts, tirés du diagnostic
+// même ; numero vient de numeroDe, tours de la charge.
+const SEPARATION = {
+ cle: 'diagnostic.remede.separation',
+ details: {},
+ commande: 'modifierReglage',
+ arguments: { cle: 'separerAppartenances', valeur: false },
+};
+const remedeCollisions = ({ tablesManquantes }) =>
+ tablesManquantes === null
+ ? { cle: 'diagnostic.remede.redistribuer', details: {} }
+ : { cle: 'diagnostic.remede.tables', details: { tablesManquantes } };
+
+const CHAMPS = [
+ {
+ champ: 'uneSeuleAppartenance',
+ attendus: (d) => (d.uneSeuleAppartenance ? [{ cle: 'diagnostic.uneSeuleAppartenance', details: {}, remede: SEPARATION }] : []),
+ },
+ {
+ champ: 'collisionPartout',
+ attendus: (d) => (d.collisionPartout ? [{ cle: 'diagnostic.collisionPartout', details: {}, remede: SEPARATION }] : []),
+ },
+ {
+ champ: 'collisions',
+ attendus: (d, tours) =>
+ d.collisions.map((ligne) => ({
+ cle: 'diagnostic.collisions',
+ details: {
+ groupe: ligne.groupe,
+ effectif: ligne.effectif,
+ plancherCumulees: ligne.plancherCumulees,
+ plancherPairesDistinctes: ligne.plancherPairesDistinctes,
+ tours,
+ },
+ remede: remedeCollisions(ligne),
+ })),
+ },
+ {
+ champ: 'collisions[].plancherExcedent',
+ attendus: (d) =>
+ d.collisions
+ .filter(({ plancherExcedent }) => plancherExcedent !== null)
+ .map((ligne) => ({
+ cle: 'diagnostic.excedent',
+ details: { groupe: ligne.groupe, plancherExcedent: ligne.plancherExcedent },
+ remede: remedeCollisions(ligne),
+ })),
+ },
+ {
+ champ: 'redondanceMinimale',
+ attendus: (d, tours) =>
+ d.redondanceMinimale === null || d.redondanceMinimale === 0
+ ? []
+ : [
+ {
+ cle: 'diagnostic.redondance',
+ details: { redondanceMinimale: d.redondanceMinimale },
+ remede:
+ d.remedes.toursSansRedondance === null
+ ? { cle: 'diagnostic.remede.appartenances', details: {} }
+ : { cle: 'diagnostic.remede.tours', details: { toursSansRedondance: d.remedes.toursSansRedondance, tours } },
+ },
+ ],
+ },
+ {
+ champ: 'animateursMemeAppartenance',
+ attendus: (d) =>
+ d.animateursMemeAppartenance && d.redondanceImposeeParAnimateurs > 0
+ ? [
+ {
+ cle: 'diagnostic.animateurs',
+ details: { redondanceImposeeParAnimateurs: d.redondanceImposeeParAnimateurs },
+ remede: { cle: 'diagnostic.remede.animateurs', details: {} },
+ },
+ ]
+ : [],
+ },
+ {
+ champ: 'ancrage',
+ attendus: (d, _tours, numeroDe) =>
+ d.ancrage
+ .filter(({ gelee, moitie }) => gelee || moitie)
+ .map(({ table, ancres, capacite, gelee }) => ({
+ cle: gelee ? 'diagnostic.ancrage.gelee' : 'diagnostic.ancrage.moitie',
+ details: { numero: numeroDe(table), ancres, capacite },
+ remede: { cle: 'diagnostic.remede.ancrage', details: {} },
+ })),
+ },
+ {
+ champ: 'ecartItineraire',
+ attendus: (d) =>
+ d.ecartItineraire === null
+ ? []
+ : [
+ {
+ cle: 'diagnostic.ecartItineraire',
+ details: { plancher: d.ecartItineraire.plancher, mobilesAuMoins: d.ecartItineraire.mobilesAuMoins },
+ remede: { cle: 'diagnostic.remede.ecartItineraire', details: {} },
+ },
+ ],
+ },
+ {
+ champ: 'placesManquantes',
+ attendus: (d) =>
+ d.placesManquantes > 0
+ ? [
+ {
+ cle: 'diagnostic.placesManquantes',
+ details: { placesManquantes: d.placesManquantes },
+ remede: { cle: 'diagnostic.remede.places', details: {} },
+ },
+ ]
+ : [],
+ },
+ {
+ champ: 'plafondAPriori',
+ attendus: (d) =>
+ d.plafondAPriori.repartition.length === 0
+ ? [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }]
+ : d.plafondAPriori.repartition.map(({ valeur, ancres, mobiles }) => ({
+ cle: 'diagnostic.plafond',
+ details: { valeur, ancres, mobiles },
+ remede: { cle: 'diagnostic.remede.plafond', details: {} },
+ })),
+ },
+ {
+ champ: 'retoursImposes',
+ attendus: (d) =>
+ d.retoursImposes > 0
+ ? [
+ {
+ cle: 'diagnostic.imposes',
+ details: { retoursImposes: d.retoursImposes, ancragesImposantRetours: d.ancragesImposantRetours },
+ remede: { cle: 'diagnostic.remede.imposes', details: {} },
+ },
+ ]
+ : [],
+ },
+];
+
+describe('enonces : chaque champ a son énoncé', () => {
+ test('sur la suite des configurations, chaque champ non nul a ses énoncés, dans l\u{2019}ordre, et rien d\u{2019}autre', () => {
+ const exerces = new Set();
+ for (const [nom, construire] of Object.entries(CONFIGURATIONS)) {
+ const charge = construire();
+ const { diagnostic, liste } = lire(charge);
+ const numeroDe = numeroDeCharge(charge);
+ // Le moteur, sollicité une seconde fois, sur la même configuration.
+ const moteur = diagnostiquer(configurationDepuisCharge(charge));
+ const attendue = CHAMPS.flatMap(({ champ, attendus }) => {
+ const lignes = attendus(moteur, charge.evenement.tours, numeroDe);
+ if (lignes.length > 0 && !(champ === 'plafondAPriori' && moteur.plafondAPriori.repartition.length === 0)) {
+ exerces.add(champ);
+ }
+ return lignes;
+ });
+ assert.ok(attendue.length > 0, nom);
+ assert.deepEqual(liste, attendue, nom);
+ assert.deepEqual(diagnostic, moteur, nom);
+ for (const enonce of liste) {
+ // Chaque texte existe ; chaque énoncé d'un champ non nul a un remède.
+ assert.equal(typeof texte(enonce), 'string');
+ if (enonce.cle !== 'diagnostic.plafondInconnu') assert.notEqual(enonce.remede, null, enonce.cle);
+ if (enonce.remede !== null) assert.equal(typeof texte(enonce.remede), 'string');
+ }
+ }
+ assert.deepEqual([...exerces].sort(), CHAMPS.map(({ champ }) => champ).sort());
+ });
+
+ test('collisionPartout vrai : l\u{2019}énoncé et son remède, exécutable par modifierReglage', () => {
+ const { diagnostic, liste } = lire(CONFIGURATIONS.toutesEnConflit());
+ assert.equal(diagnostic.collisionPartout, true);
+ assert.deepEqual(avecCle(liste, 'diagnostic.collisionPartout'), [
+ {
+ cle: 'diagnostic.collisionPartout',
+ details: {},
+ remede: {
+ cle: 'diagnostic.remede.separation',
+ details: {},
+ commande: 'modifierReglage',
+ arguments: { cle: 'separerAppartenances', valeur: false },
+ },
+ },
+ ]);
+ });
+});
+
+describe('enonces : les phrases du § 5.6', () => {
+ test('collisions : la petite au conflit inévitable, le groupe de 5, ses deux planchers et leurs unités', () => {
+ const charge = CONFIGURATIONS.petiteConflit();
+ const { diagnostic, liste } = lire(charge);
+ const [ligne] = diagnostic.collisions;
+ assert.equal(diagnostic.collisions.length, 1);
+ const [enonce] = avecCle(liste, 'diagnostic.collisions');
+ assert.equal(enonce.details.effectif, 5);
+ assert.equal(enonce.details.plancherCumulees, ligne.plancherCumulees);
+ assert.equal(enonce.details.plancherPairesDistinctes, ligne.plancherPairesDistinctes);
+ assert.equal(enonce.details.groupe, ligne.groupe);
+ const phrase = texte(enonce);
+ assert.ok(phrase.includes('au moins 4 collisions cumulées sur les 4 tours, portées par au moins 1 paire distincte'), phrase);
+ assert.ok(phrase.includes('5 personnes'), phrase);
+ assert.ok(!phrase.includes(ligne.groupe), 'l\u{2019}appartenance occupe son propre nœud');
+ assert.doesNotMatch(phrase, /\b4 paires?\b/);
+ // Chaque chiffre nomme son unité.
+ const nombres = [...phrase.matchAll(/\d+/g)];
+ assert.equal(nombres.length, 4);
+ for (const nombre of nombres) {
+ assert.match(phrase.slice(nombre.index), /^\d+ (personnes?|collisions? cumulées?|tours?|paires? distinctes?)\b/, phrase);
+ }
+ // 4 ≤ C(5, 2) : aucun excédent.
+ assert.equal(ligne.plancherExcedent, null);
+ assert.equal(avecCle(liste, 'diagnostic.excedent').length, 0);
+ assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.tables', details: { tablesManquantes: 1 } });
+ assert.ok(texte(enonce.remede).includes('1 table'));
+ });
+
+ test('excédent : trois membres, deux tables, quatre tours — « excédent d\u{2019}au moins 1 », jamais un plancher nul', () => {
+ const { diagnostic, liste } = lire(CONFIGURATIONS.excedent());
+ assert.equal(diagnostic.collisions[0].plancherExcedent, 1);
+ const [enonce] = avecCle(liste, 'diagnostic.excedent');
+ const phrase = texte(enonce);
+ assert.ok(phrase.includes("excédent d'au moins 1 "), phrase);
+ assert.doesNotMatch(phrase, /≥|\b0\b/);
+ // Une configuration à excédent porte au moins un énoncé d'excédent, et
+ // chacun un plancher positif.
+ for (const cle of ['excedent', 'memeTable']) {
+ const portes = avecCle(lire(CONFIGURATIONS[cle]()).liste, 'diagnostic.excedent');
+ assert.ok(portes.length > 0, cle);
+ for (const { details } of portes) assert.ok(details.plancherExcedent > 0, `${cle} : ${details.plancherExcedent}`);
+ }
+ // Une configuration sans excédent n'en porte aucun énoncé : chaque ligne
+ // de collisions y a un plancher d'excédent null, jamais 0. Les deux
+ // configurations en conflit ont au moins une ligne, la petite aucune.
+ for (const [cle, avecCollisions] of [['petiteConflit', true], ['toutesEnConflit', true], ['petite', false]]) {
+ const lu = lire(CONFIGURATIONS[cle]());
+ assert.equal(avecCle(lu.liste, 'diagnostic.excedent').length, 0, cle);
+ assert.equal(lu.diagnostic.collisions.length > 0, avecCollisions, cle);
+ for (const ligne of lu.diagnostic.collisions) assert.equal(ligne.plancherExcedent, null, `${cle} : ${ligne.groupe}`);
+ }
+ });
+
+ test('deux membres réservés à une même table : le remède redistribue, aucune table ne suffit', () => {
+ const { diagnostic, liste } = lire(CONFIGURATIONS.memeTable());
+ assert.equal(diagnostic.collisions[0].tablesManquantes, null);
+ const portes = [...avecCle(liste, 'diagnostic.collisions'), ...avecCle(liste, 'diagnostic.excedent')];
+ assert.equal(portes.length, 2);
+ for (const enonce of portes) {
+ assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.redistribuer', details: {} });
+ }
+ });
+
+ test('redondance : la configuration de M1 — au moins 6 appartenances recroisées ; avec 3 tours au lieu de 4, aucune', () => {
+ const { diagnostic, liste } = lire(CONFIGURATIONS.redondance());
+ assert.equal(diagnostic.redondanceMinimale, 6);
+ const [enonce] = avecCle(liste, 'diagnostic.redondance');
+ assert.equal(texte(enonce), 'au moins 6 appartenances recroisées par personne');
+ assert.equal(texte(enonce.remede), 'avec 3 tours au lieu de 4, aucune ne le serait');
+ });
+
+ test('redondance sans remède en tours : un seul tour, toursSansRedondance null', () => {
+ const { diagnostic, liste } = lire(CONFIGURATIONS.unTour());
+ assert.ok(diagnostic.redondanceMinimale > 0);
+ assert.equal(diagnostic.remedes.toursSansRedondance, null);
+ const [enonce] = avecCle(liste, 'diagnostic.redondance');
+ assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.appartenances', details: {} });
+ assert.doesNotMatch(texte(enonce.remede), /\d/);
+ for (const { remede } of liste) assert.notEqual(remede?.cle, 'diagnostic.remede.tours');
+ });
+
+ test('animateurs d\u{2019}une même appartenance : la phrase porte 3 et son remède ; absente de la grande', () => {
+ const { diagnostic, liste } = lire(CONFIGURATIONS.animateurs());
+ assert.equal(diagnostic.redondanceImposeeParAnimateurs, 3);
+ const [enonce] = avecCle(liste, 'diagnostic.animateurs');
+ assert.ok(texte(enonce).includes('au moins 3 redondances'), texte(enonce));
+ assert.equal(enonce.remede.cle, 'diagnostic.remede.animateurs');
+ assert.ok(texte(enonce.remede).includes('sans appartenance déclarée'));
+ assert.equal(avecCle(lire(CONFIGURATIONS.grande()).liste, 'diagnostic.animateurs').length, 0);
+ });
+
+ test('ancrage : une table de 3 à 3 ancrés ne tourne plus, par son numéro affiché ; une de 4 à 2 ancrés, 2 sur 4', () => {
+ const { liste } = lire(CONFIGURATIONS.ancrage());
+ const [gelee] = avecCle(liste, 'diagnostic.ancrage.gelee');
+ const [moitie] = avecCle(liste, 'diagnostic.ancrage.moitie');
+ assert.equal(avecCle(liste, 'diagnostic.ancrage.gelee').length, 1);
+ assert.equal(avecCle(liste, 'diagnostic.ancrage.moitie').length, 1);
+ assert.equal(texte(gelee), 'la table 7 ne tourne plus : ses 3 places sont ancrées');
+ assert.deepEqual(moitie.details, { numero: 8, ancres: 2, capacite: 4 });
+ assert.ok(texte(moitie).startsWith('la table 8 a 2 places ancrées sur 4'), texte(moitie));
+ });
+
+ test('écart d\u{2019}itinéraire : la grande, plancher 1, au moins 24 mobiles ; la petite, aucune mention, jamais 0', () => {
+ const grande = lire(CONFIGURATIONS.grande());
+ const [enonce] = avecCle(grande.liste, 'diagnostic.ecartItineraire');
+ assert.deepEqual(enonce.details, grande.diagnostic.ecartItineraire);
+ assert.ok(texte(enonce).includes('plancher 1, au moins 24 mobiles'), texte(enonce));
+ for (const cle of ['petite', 'petiteConflit', 'grandeSansException']) {
+ assert.equal(avecCle(lire(CONFIGURATIONS[cle]()).liste, 'diagnostic.ecartItineraire').length, 0, cle);
+ }
+ });
+
+ test('manque de places : treize personnes, quatre tables de 3 — 1 place manque à chaque tour', () => {
+ const [enonce] = avecCle(lire(CONFIGURATIONS.manque()).liste, 'diagnostic.placesManquantes');
+ assert.equal(texte(enonce), '1 place manque à chaque tour');
+ });
+
+ test('plafond a priori : la grande, 24 pour les 4 ancrés, 28 pour 29 ancrés et 227 mobiles, depuis repartition', () => {
+ const { diagnostic, liste } = lire(CONFIGURATIONS.grande());
+ const plafonds = avecCle(liste, 'diagnostic.plafond');
+ assert.deepEqual(
+ plafonds.map(({ details }) => details),
+ diagnostic.plafondAPriori.repartition,
+ );
+ assert.deepEqual(diagnostic.plafondAPriori.repartition, [
+ { valeur: 24, ancres: 4, mobiles: 0 },
+ { valeur: 28, ancres: 29, mobiles: 227 },
+ ]);
+ assert.equal(texte(plafonds[0]), 'plafond a priori 24 rencontres pour 4 ancrés');
+ assert.equal(texte(plafonds[1]), 'plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles');
+ });
+
+ test('imposés : la grande, « 99 retours imposés par 33 ancrages »', () => {
+ const [enonce] = avecCle(lire(CONFIGURATIONS.grande()).liste, 'diagnostic.imposes');
+ assert.equal(texte(enonce), '99 retours imposés par 33 ancrages');
+ });
+
+ test('une seule appartenance : la phrase, puis le remède qui désactive la séparation', () => {
+ const { liste } = lire(CONFIGURATIONS.uneSeule());
+ assert.equal(liste[0].cle, 'diagnostic.uneSeuleAppartenance');
+ assert.deepEqual(liste[0].remede, SEPARATION);
+ });
+});
+
+describe('enonces : aux bords', () => {
+ test('un événement neuf, zéro participant, zéro table : aucun plancher, le plafond a priori « — », jamais 0', () => {
+ const { liste } = lire(chargeDe({ appartenances: [], capacites: [] }));
+ assert.deepEqual(liste, [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }]);
+ assert.ok(texte(liste[0]).includes(TIRET));
+ assert.doesNotMatch(texte(liste[0]), /\d/);
+ });
+
+ test('deux personnes sans table : 2 places manquent à chaque tour', () => {
+ const [enonce] = avecCle(lire(chargeDe({ appartenances: [null, null], capacites: [] })).liste, 'diagnostic.placesManquantes');
+ assert.equal(texte(enonce), '2 places manquent à chaque tour');
+ });
+
+ test('aucune appartenance : ni collisions ni redondance', () => {
+ const { liste } = lire(chargeDe({ appartenances: fois(12, null), capacites: fois(4, 3) }));
+ assert.ok(liste.length > 0);
+ for (const cle of ['diagnostic.collisions', 'diagnostic.excedent', 'diagnostic.redondance', 'diagnostic.uneSeuleAppartenance', 'diagnostic.collisionPartout']) {
+ assert.equal(avecCle(liste, cle).length, 0, cle);
+ }
+ });
+
+ test('numeroDeCharge : le numéro affiché d\u{2019}un identifiant de table ; un identifiant inconnu lève', () => {
+ const numeroDe = numeroDeCharge(CONFIGURATIONS.ancrage());
+ assert.deepEqual([1, 2, 3].map(numeroDe), [7, 8, 9]);
+ assert.throws(() => numeroDe(4), RangeError);
+ });
+
+ test('enonces ne modifie pas le diagnostic reçu', () => {
+ const charge = CONFIGURATIONS.ancrage();
+ const { diagnostic } = diagnostiquerCharge(charge);
+ const avant = structuredClone(diagnostic);
+ enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: 4 });
+ assert.deepEqual(diagnostic, avant);
+ });
+});
diff --git a/src/interface/libelles/diagnostic.js b/src/interface/libelles/diagnostic.js
index ac6e261..62f961d 100644
--- a/src/interface/libelles/diagnostic.js
+++ b/src/interface/libelles/diagnostic.js
@@ -1,6 +1,126 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
-// Les libellés du diagnostic d'une configuration (§ 14.6). Chaque clé commence
-// par « diagnostic. ».
-export const TABLE = new Map();
+// Les libellés du diagnostic d'une configuration (§ 5.6, § 14.6). Chaque clé
+// commence par « diagnostic. ». Un énoncé (phrases.js) a son texte sous sa
+// clé, son remède sous diagnostic.remede.. Chaque chiffre nomme son
+// unité : une collision se compte en occurrences, une paire distincte à
+// part (§ 5.4). L'appartenance d'un groupe est une donnée : l'écran la pose
+// dans son propre nœud, avant le texte, qu'aucune entrée de cette table ne
+// lui fait porter.
+import { compte, enumeration } from '../../application/libelles/aides.js';
+
+// « sur le tour » ou « sur les R tours ».
+const surLesTours = (tours) => (tours === 1 ? 'sur le tour' : `sur les ${tours} tours`);
+
+export const TABLE = new Map([
+ // --- Le cadre de l'écran ---------------------------------------------------
+ ['diagnostic.enonces', 'Ce que la configuration impose'],
+ ['diagnostic.nonCalcule', 'La configuration ne se normalise pas : le diagnostic ne se calcule pas.'],
+
+ // --- La séparation vidée de son sens (§ 7.5, § 15.4) -----------------------
+ [
+ 'diagnostic.uneSeuleAppartenance',
+ 'une seule appartenance, portée par chaque personne, sous séparation active : la séparation est vide de sens',
+ ],
+ ['diagnostic.collisionPartout', 'tout placement porte une collision à chaque table, à chaque tour'],
+
+ // --- Les collisions et l'excédent (§ 5.4, § 5.6) ---------------------------
+ [
+ 'diagnostic.collisions',
+ ({ effectif, plancherCumulees, plancherPairesDistinctes, tours }) =>
+ `${compte(effectif, 'personne', 'personnes')} : au moins ` +
+ `${compte(plancherCumulees, 'collision cumulée', 'collisions cumulées')} ${surLesTours(tours)}, ` +
+ `${plancherCumulees === 1 ? 'portée' : 'portées'} par au moins ` +
+ compte(plancherPairesDistinctes, 'paire distincte', 'paires distinctes'),
+ ],
+ [
+ 'diagnostic.excedent',
+ ({ plancherExcedent }) =>
+ `excédent d'au moins ${compte(plancherExcedent, 'collision', 'collisions')} : des retrouvailles sont forcées`,
+ ],
+
+ // --- La redondance et les animateurs (§ 5.6) -------------------------------
+ [
+ 'diagnostic.redondance',
+ ({ redondanceMinimale }) =>
+ `au moins ${compte(redondanceMinimale, 'appartenance recroisée', 'appartenances recroisées')} par personne`,
+ ],
+ [
+ 'diagnostic.animateurs',
+ ({ redondanceImposeeParAnimateurs }) =>
+ 'tous les animateurs partagent une appartenance : chaque mobile accumule au moins ' +
+ `${compte(redondanceImposeeParAnimateurs, 'redondance', 'redondances')} qu'aucune recherche ne réduit`,
+ ],
+
+ // --- L'ancrage par table (§ 5.6, § 5.9) ------------------------------------
+ ['diagnostic.ancrage.gelee', ({ numero, capacite }) => `la table ${numero} ne tourne plus : ses ${capacite} places sont ancrées`],
+ [
+ 'diagnostic.ancrage.moitie',
+ ({ numero, ancres, capacite }) =>
+ `la table ${numero} a ${compte(ancres, 'place ancrée', 'places ancrées')} sur ${capacite} : ` +
+ "elle réduit d'autant le brassage de tout l'événement",
+ ],
+
+ // --- L'écart d'itinéraire, le manque de places (§ 5.6, § 12.10.4) ----------
+ [
+ 'diagnostic.ecartItineraire',
+ ({ plancher, mobilesAuMoins }) =>
+ `écart d'itinéraire maximal : plancher ${plancher}, au moins ` +
+ (mobilesAuMoins === 1 ? '1 mobile finit sous son plafond a priori' : `${mobilesAuMoins} mobiles finissent sous leur plafond a priori`),
+ ],
+ [
+ 'diagnostic.placesManquantes',
+ ({ placesManquantes }) =>
+ placesManquantes === 1 ? '1 place manque à chaque tour' : `${placesManquantes} places manquent à chaque tour`,
+ ],
+
+ // --- Le plafond a priori et les imposés (§ 5.4, § 5.5) ---------------------
+ [
+ 'diagnostic.plafond',
+ ({ valeur, ancres, mobiles }) =>
+ `plafond a priori ${compte(valeur, 'rencontre', 'rencontres')} pour ` +
+ enumeration([
+ ...(ancres > 0 ? [compte(ancres, 'ancré', 'ancrés')] : []),
+ ...(mobiles > 0 ? [compte(mobiles, 'mobile', 'mobiles')] : []),
+ ]),
+ ],
+ ['diagnostic.plafondInconnu', (_, texte) => `plafond a priori : ${texte('format.vide')}`],
+ [
+ 'diagnostic.imposes',
+ ({ retoursImposes, ancragesImposantRetours }) =>
+ `${compte(retoursImposes, 'retour imposé', 'retours imposés')} par ${compte(ancragesImposantRetours, 'ancrage', 'ancrages')}`,
+ ],
+
+ // --- Les remèdes -----------------------------------------------------------
+ ['diagnostic.remede.separation', 'désactiver la séparation des appartenances'],
+ [
+ 'diagnostic.remede.tables',
+ ({ tablesManquantes }) =>
+ tablesManquantes === 1
+ ? '1 table de plus, de la plus grande capacité présente, la rend évitable'
+ : `${tablesManquantes} tables de plus, de la plus grande capacité présente, la rendent évitable`,
+ ],
+ [
+ 'diagnostic.remede.redistribuer',
+ "les réservations assoient des membres ensemble : répartir autrement l'appartenance la rend évitable",
+ ],
+ [
+ 'diagnostic.remede.tours',
+ ({ toursSansRedondance, tours }) =>
+ `avec ${compte(toursSansRedondance, 'tour', 'tours')} au lieu de ${tours}, aucune ne le serait`,
+ ],
+ ['diagnostic.remede.appartenances', "aucun nombre de tours plus petit ne l'annule : elle tient aux appartenances déclarées"],
+ ['diagnostic.remede.animateurs', 'laisser les animateurs sans appartenance déclarée, ou leur en donner des distinctes'],
+ [
+ 'diagnostic.remede.ancrage',
+ 'retirer des réservations de portée « tous les tours » à cette table, ou lui ajouter des places, la remet en rotation',
+ ],
+ [
+ 'diagnostic.remede.ecartItineraire',
+ "aucune relance ne le réduit : il tient aux tables pleines dont les capacités ou les ancrages diffèrent",
+ ],
+ ['diagnostic.remede.places', "ajouter des tables ou des places, ou exclure des personnes : d'ici là, la génération est refusée"],
+ ['diagnostic.remede.plafond', 'seules les capacités et les réservations le changent : aucune relance ne le dépasse'],
+ ['diagnostic.remede.imposes', 'les réservations les imposent : ils ne comptent pas parmi les retours que choisit le moteur'],
+]);
diff --git a/src/interface/plan/SectionPlan.svelte b/src/interface/plan/SectionPlan.svelte
index 3d0a244..9803d78 100644
--- a/src/interface/plan/SectionPlan.svelte
+++ b/src/interface/plan/SectionPlan.svelte
@@ -14,6 +14,7 @@
// surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le
// cadre l'entoure toujours (§ 8.4).
import { getContext } from 'svelte';
+ import { diagnostiquerCharge } from '../../application/diagnostic.js';
import Diagnostic from '../diagnostic/Diagnostic.svelte';
import ListesTravail from './ListesTravail.svelte';
import PanneauPlace from './PanneauPlace.svelte';
@@ -26,6 +27,11 @@
const ui = getContext('ui');
const evenement = $derived($seance.charge === null ? null : $seance.charge.evenement.id);
+ // Le diagnostic de la charge, que Plan donne au modèle de dessin : quand
+ // toutes les tables sont en conflit, le bandeau du plan en énonce les
+ // planchers (§ 7.5) ; null quand la configuration ne se normalise pas, et
+ // le bandeau ne porte que la phrase.
+ const diagnostic = $derived($seance.charge === null ? null : diagnostiquerCharge($seance.charge).diagnostic);
@@ -35,7 +41,7 @@
{#if evenement !== null}
{#key evenement}
-
+
{/key}
{/if}
{#if !$ui.pleinEcran}