[ADD] interface: diagnostic — what a configuration makes unavoidable

Before generating, the operator must know what no plan can avoid: the
encounters a table size forces, the conflicts when every table holds
colleagues, the anchors that impose a return. The engine's diagnosis is
turned into sentences with their figures and their remedies, shown in its
section and, compact, beside the plan, whose banner states the floors
when every table is in conflict (§ 7.5).
Checked: 2425 node and 413 browser tests.

--- FR ---

[ADD] interface : diagnostic — ce qu'une configuration rend inévitable

Avant de générer, l'opérateur doit savoir ce qu'aucun plan n'évite : les
rencontres qu'une taille de table impose, les conflits quand chaque table
réunit des collègues, les ancrages qui imposent un retour. Le diagnostic du
moteur devient des phrases, avec leurs chiffres et leurs remèdes, montrées
dans sa section et, en compact, à côté du plan, dont le bandeau énonce les
planchers quand toutes les tables sont en conflit (§ 7.5).
Vérifié : 2425 node et 413 navigateur.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 09:41:51 -04:00
parent ad19131954
commit 626159db5f
8 changed files with 1438 additions and 8 deletions

View file

@ -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 } };
}
}

View file

@ -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);
});
});

View file

@ -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'));
});
});

View file

@ -2,12 +2,149 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script> <script>
// La section du diagnostic sous sa forme minimale : son titre, tiré des libellés de la coquille. // Le diagnostic de la configuration (§ 2.1 étape 8, § 5.6) : ce que la
// configuration rend inévitable, combien, et ce qui le rendrait évitable.
// Il se calcule sur la charge de l'état observable à chaque charge écrite,
// par diagnostiquerCharge, et s'énonce par phrases.js : le composant ne
// fait que traduire. Regarder n'écrit jamais (§ 8.4) : il se lit en
// lecture comme sur un plan bloqué.
//
// compact : la forme du panneau latéral du plan, que SectionPlan monte
// dans [data-panneau-diagnostic] — son titre et les énoncés seuls. Sinon, la section,
// [data-section="diagnostic"] : chaque énoncé suivi du texte de son
// remède et, quand une commande le réalise, de la Commande qui l'exécute
// par la séance ; un refus de la séance — mode lecture, plan bloqué —
// s'affiche à côté du bouton, avec son propre remède.
//
// Une configuration que le moteur refuse n'a pas de diagnostic : son
// code, traduit, se pose à sa place, jamais le message de l'erreur.
// L'appartenance d'un groupe est une donnée : elle occupe son propre
// nœud, avant le texte.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { actionRemede } from '../primitives/commande.js';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import { enonces, numeroDeCharge } from './phrases.js';
let { compact = false } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui') ?? null;
const charge = $derived($seance.charge);
const resultat = $derived(charge === null ? null : diagnostiquerCharge(charge));
const liste = $derived(
resultat === null || resultat.diagnostic === null
? []
: enonces(resultat.diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }),
);
// L'action d'un remède que réalise une commande ; null pour un remède de
// texte seul.
const actionDe = (remede) => (remede?.commande === undefined ? null : actionRemede(remede, { seance, ui }));
</script> </script>
<section data-section="diagnostic"> {#snippet contenu()}
<h2>{t('coquille.section.diagnostic')}</h2> {#if resultat !== null && resultat.erreur !== null}
</section> <p>{t('diagnostic.nonCalcule')}</p>
<Refus refus={resultat.erreur} />
{:else if liste.length > 0}
<ul aria-label={t('diagnostic.enonces')}>
{#each liste as enonce, rang (rang)}
<li data-enonce={enonce.cle}>
<p class="enonce">
{#if enonce.details.groupe !== undefined}<span class="donnee" data-donnee>{enonce.details.groupe}</span>{/if}<span
data-phrase>{t(enonce.cle, enonce.details)}</span
>
</p>
{#if !compact && enonce.remede !== null}
{@const action = actionDe(enonce.remede)}
<p class="remede" data-remede>{t(enonce.remede.cle, enonce.remede.details)}</p>
{#if action !== null}
<Commande
cle={action.cle}
details={action.details}
nom={action.nom}
confirmer={action.confirmer}
executer={action.executer}
/>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
{/snippet}
{#if compact}
<div class="diagnostic compact">
<h3>{t('coquille.section.diagnostic')}</h3>
{@render contenu()}
</div>
{:else}
<section class="diagnostic" data-section="diagnostic">
<h2>{t('coquille.section.diagnostic')}</h2>
{@render contenu()}
</section>
{/if}
<style>
.diagnostic {
display: flex;
flex-direction: column;
gap: var(--espace-2);
min-width: 0;
color: var(--couleur-page-texte);
font-size: var(--taille-texte);
}
h2 {
margin: 0;
font-size: var(--taille-titre);
}
h3 {
margin: 0;
font-size: var(--taille-texte);
}
ul {
display: flex;
flex-direction: column;
gap: var(--espace-3);
margin: 0;
padding: 0;
list-style: none;
}
.compact ul {
gap: var(--espace-2);
}
li {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
p {
margin: 0;
}
.enonce {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.donnee {
font-weight: 700;
}
.remede {
color: var(--couleur-page-texte-secondaire);
}
</style>

View file

@ -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;
};
}

View file

@ -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);
});
});

View file

@ -1,6 +1,126 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // 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 // Les libellés du diagnostic d'une configuration (§ 5.6, § 14.6). Chaque clé
// par « diagnostic. ». // commence par « diagnostic. ». Un énoncé (phrases.js) a son texte sous sa
export const TABLE = new Map(); // clé, son remède sous diagnostic.remede.<nom>. 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'],
]);

View file

@ -14,6 +14,7 @@
// surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le // surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le
// cadre l'entoure toujours (§ 8.4). // cadre l'entoure toujours (§ 8.4).
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { diagnostiquerCharge } from '../../application/diagnostic.js';
import Diagnostic from '../diagnostic/Diagnostic.svelte'; import Diagnostic from '../diagnostic/Diagnostic.svelte';
import ListesTravail from './ListesTravail.svelte'; import ListesTravail from './ListesTravail.svelte';
import PanneauPlace from './PanneauPlace.svelte'; import PanneauPlace from './PanneauPlace.svelte';
@ -26,6 +27,11 @@
const ui = getContext('ui'); const ui = getContext('ui');
const evenement = $derived($seance.charge === null ? null : $seance.charge.evenement.id); 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);
</script> </script>
<section data-section="plan" class="section-plan"> <section data-section="plan" class="section-plan">
@ -35,7 +41,7 @@
<div class="corps"> <div class="corps">
{#if evenement !== null} {#if evenement !== null}
{#key evenement} {#key evenement}
<Plan diagnostic={null} /> <Plan {diagnostic} />
{/key} {/key}
{/if} {/if}
{#if !$ui.pleinEcran} {#if !$ui.pleinEcran}