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} + + {/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}