diff --git a/src/interface/impression/FeuillesTables.svelte b/src/interface/impression/FeuillesTables.svelte index 676bef6..ab7cfba 100644 --- a/src/interface/impression/FeuillesTables.svelte +++ b/src/interface/impression/FeuillesTables.svelte @@ -2,33 +2,202 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> - - {#snippet contenu()} -

{t('impression.tables.titre')}

- {/snippet} -
+{#snippet blocTable(liste, rangListe, debut, fin)} +
+

{t('feuilles.tables.titre', { table: liste.numero })}

+ + + {#each largeursColonnes('tables', liste.tours.length) as largeur, rang (rang)} + + {/each} + + + + + {#each liste.tours as tour (tour)} + + {/each} + + + + {#each liste.lignes.slice(debut, fin) as ligne, rang (ligne.place)} + + + {#each ligne.cellules as cellule (cellule.tour)} + + {/each} + + {/each} + +
{t('feuilles.tables.place')}{t('feuilles.tour', { tour })}
{ligne.place} + {#if cellule.nom === null} + {t('feuilles.tables.vide')} + {:else} + {cellule.nom} + {/if} + {#if cellule.titre !== null} + + {cellule.titre.libelle} + {#if !cellule.titre.pourvu} + {t('feuilles.tables.titreNonPourvu')} + {/if} + + {/if} +
+
+{/snippet} + +{#if bandeMm === null} + +{:else if pages === null} + +{:else if pages.length === 0} + + {#snippet contenu()} +

{t('feuilles.tables.aucune')}

+ {/snippet} +
+{:else} + {#each pages as page (page.rang)} + + {#snippet contenu()} + {@render blocTable(listes[page.feuille], page.feuille, page.debut, page.fin)} + {/snippet} + + {/each} +{/if} diff --git a/src/interface/impression/ListeAccueil.svelte b/src/interface/impression/ListeAccueil.svelte index 0a53ca9..52403c3 100644 --- a/src/interface/impression/ListeAccueil.svelte +++ b/src/interface/impression/ListeAccueil.svelte @@ -2,33 +2,222 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> - - {#snippet contenu()} -

{t('impression.accueil.titre')}

- {/snippet} -
+{#snippet liste(debut, fin)} +
+

{t('feuilles.accueil.titre')}

+ + + {#each largeurs as largeur, rang (rang)} + + {/each} + + + + + + + {#each tours as tour (tour)} + + {/each} + + + + {#each lignes.slice(debut, fin) as ligne, rang (ligne.participant)} + + + + + {#each ligne.places as place (place.tour)} + {@const libelle = libellePlace(place)} + + {/each} + + {/each} + +
{t('feuilles.accueil.nom')}{t('feuilles.accueil.prenom')}{t('feuilles.accueil.appartenance')}{t('feuilles.tour', { tour })}
{ligne.nom} + {#if ligne.prenom !== null}{ligne.prenom}{/if} + + {#if ligne.appartenance !== null}{ligne.appartenance}{/if} + {t(libelle.cle, libelle.details)}
+
+{/snippet} + +{#if bandeMm === null} + +{:else if pages === null} + +{:else} + {#each pages as page (page.rang)} + + {#snippet contenu()} + {@render liste(page.debut, page.fin)} + {/snippet} + + {/each} +{/if} diff --git a/src/interface/impression/Listes.navigateur.test.js b/src/interface/impression/Listes.navigateur.test.js new file mode 100644 index 0000000..5a763cf --- /dev/null +++ b/src/interface/impression/Listes.navigateur.test.js @@ -0,0 +1,338 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La liste d'accueil et la feuille par table imprimées, dans un vrai moteur +// de rendu (§ 11.7, § 7.4, § 11.4, Q6 c) : un nom de deux cents caractères +// paraît entier et sa ligne replie, aucune marque d'abréviation ; les +// feuilles sont les pages de paginer sur les hauteurs rendues, chacune sous +// son en-tête et son pied, aucune ligne hors de sa zone de contenu ; les +// colonnes de tour de la liste d'accueil à la largeur de leur texte, chaque +// place sur une ligne tant que le plancher des noms le permet ; chaque +// place d'une table listée, une place vide écrite comme telle, les titres +// nommés, « non pourvu » quand rien ne les pourvoit ; « Une feuille par +// tour » donne une feuille par table et par tour, chacune au pied et au +// filigrane de son tour, sans rien écrire, en lecture comme sur un plan +// bloqué (§ 8.4). +// +// App est montée par test/navigateur/ecrans.js sur le système de fichiers +// simulé, au cadre de 1 366 × 700 ; le texte attendu vient de la même +// traduction que celle de l'application. Les noms d'épreuve sont inventés, +// hors du réservoir des démonstrations. +// +// Le projet navigateur emploie expect, et test et describe de Vitest +// (§ 14.8). +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { appliquerCommande } from '../../application/commandes.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { nomAffiche } from '../../application/personnes.js'; +import { estBrouillon } from '../../application/sorties.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { texteLivre } from '../../demo/livrees.js'; +import { analyser } from '../../stockage/document.js'; +import { fixerCadre } from '../../../test/navigateur/entrees.js'; +import { attendre, chargeBloquee, chargePrincipale, cliquer, creerContexte, ouvrirApercu } from '../../../test/navigateur/ecrans.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { paginer } from './pagination.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +// Images attendues au plus par étape : bien au-delà d'un rendu, sous le +// délai d'une épreuve. +const IMAGES = 300; + +// Un nom de deux cents caractères sans espace : rien ne le coupe qu'un +// repli au caractère. +const NOM_LONG = 'Ombrelle'.repeat(25); + +// La marque d'abréviation du plan (§ 7.4), qu'aucune de ces vues ne pose. +const ABREVIATION = '\u{2026}'; + +// Une ligne rendue dépasse d'au plus ce jeu, en px, le bas de sa zone de +// contenu : l'arrondi des boîtes au pixel. +const JEU_PX = 0.5; + +let contexte = null; +beforeEach(async () => { + await fixerCadre(1366, 700); + contexte = creerContexte(); +}); +afterEach(async () => { + const erreurs = contexte.erreurs(); + await contexte.defaire(); + expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]); +}); + +const dans = (m, selecteur) => m.racine.querySelector(selecteur); +const tous = (racine, selecteur) => [...racine.querySelectorAll(selecteur)]; +const feuilles = (m) => tous(m.racine, '[data-impression] [data-feuille]'); +const plat = (chaine) => chaine.replace(/\s+/g, ' ').trim(); +const texte = (element) => plat(element.textContent); +const boite = (element) => element.getBoundingClientRect(); + +// Choisit une vue de l'aperçu par sa commande, et attend son document. +async function choisirVue(m, vue) { + await cliquer(m, `[data-ecran="apercu"] [data-commande="vue"][data-cible="${vue}"]`); + await attendre(`la vue ${vue}`, () => dans(m, `[data-impression][data-vue-impression="${vue}"] [data-feuille]`), IMAGES); +} + +// Bascule « Une feuille par tour » et attend le document qui en suit. +async function basculerParTour(m, voulu) { + await cliquer(m, '[data-ecran="apercu"] [data-commande="parTour"]'); + await attendre(`parTour ${voulu}`, () => m.ui.etat().apercu.parTour === voulu, IMAGES); + await attendre('le document', () => feuilles(m).length > 0, IMAGES); +} + +// La principale, la personne 1 renommée d'un nom de deux cents caractères. +function chargeNomLong() { + return appliquerCommande('modifierParticipant', chargePrincipale(), { id: 1, champs: { nom: NOM_LONG } }).charge; +} + +// La principale retenue, puis un tour de plus à la soirée : le retenu porte +// les tours de 1 à R − 1, pas le dernier. +function chargeRetenuCourt() { + const principale = chargePrincipale(); + const retenue = appliquerCommande('retenirProposition', principale, { id: principale.propositions[0].id }).charge; + return appliquerCommande('changerTours', retenue, { tours: retenue.evenement.tours + 1 }).charge; +} + +// La principale, sa proposition marquée « sièges attribués » : la liste +// d'accueil écrit la table et le siège à chaque tour. +function chargeSiegesAttribues() { + const charge = structuredClone(chargePrincipale()); + for (const proposition of charge.propositions) proposition.siegesAttribues = true; + return charge; +} + +// Les lignes qu'occupe le texte d'un élément : les hauts distincts des +// boîtes de ses fragments, un par ligne. +function lignesDeTexte(element) { + const plage = document.createRange(); + plage.selectNodeContents(element); + return new Set([...plage.getClientRects()].map(({ top }) => Math.round(top))).size; +} + +// Les mots du texte d'un élément que le repli coupe sur deux lignes. +function motsCoupes(element) { + const coupes = []; + const parcours = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + for (let noeud = parcours.nextNode(); noeud !== null; noeud = parcours.nextNode()) { + for (const { 0: mot, index } of noeud.data.matchAll(/\S+/g)) { + const plage = document.createRange(); + plage.setStart(noeud, index); + plage.setEnd(noeud, index + mot.length); + if (new Set([...plage.getClientRects()].map(({ top }) => Math.round(top))).size > 1) coupes.push(mot); + } + } + return coupes; +} + +// Les cellules des colonnes de tour de la liste d'accueil — en-tête et +// lignes —, sur toutes les feuilles : celles qui suivent nom, prénom et +// appartenance. +const cellulesDeTour = (m) => + tous(m.racine, '[data-impression] [data-liste-imprimee] tr').flatMap((rangee) => [...rangee.cells].slice(3)); + +// Ce qu'une feuille imprime : sa liste, ses lignes et leur rang dans la +// liste entière, son en-tête — du haut de la liste au haut du corps —, la +// hauteur de sa zone de contenu. +function lireFeuille(feuille) { + const liste = feuille.querySelector('[data-liste-imprimee]'); + const corps = liste.querySelector('tbody'); + const lignes = tous(corps, '[data-ligne-imprimee]'); + return { + liste: Number(liste.dataset.listeImprimee), + rangs: lignes.map((ligne) => Number(ligne.dataset.ligneImprimee)), + hauteurs: lignes.map((ligne) => boite(ligne).height), + entete: boite(corps).top - boite(liste).top, + contenu: feuille.querySelector('[data-contenu]'), + lignes, + }; +} + +// Vérifie que les feuilles du document sont les pages de paginer sur les +// hauteurs rendues : liste par liste, chaque ligne une fois, dans l'ordre, +// sur les pages que paginer en fait ; chaque feuille sous son en-tête et son +// pied, aucune ligne au-delà de sa zone de contenu. Rend le nombre de +// listes. +function verifierPagination(m) { + const lues = feuilles(m).map(lireFeuille); + expect(lues.length).toBeGreaterThan(0); + const parListe = new Map(); + for (const lue of lues) { + if (!parListe.has(lue.liste)) parListe.set(lue.liste, []); + parListe.get(lue.liste).push(lue); + } + expect([...parListe.keys()]).toEqual([...parListe.keys()].map((_, rang) => rang)); + for (const [liste, pages] of parListe) { + const rangs = pages.flatMap(({ rangs: r }) => r); + expect(rangs, `liste ${liste} : chaque ligne une fois, dans l'ordre`).toEqual(rangs.map((_, rang) => rang)); + const hauteurUtile = boite(pages[0].contenu).height; + const attendues = paginer(pages.flatMap(({ hauteurs }) => hauteurs), { hauteurUtile, hauteurEntete: pages[0].entete }); + expect(pages.map(({ rangs: r }) => [r[0] ?? 0, (r.at(-1) ?? -1) + 1]), `liste ${liste}`).toEqual(attendues.map(({ debut, fin }) => [debut, fin])); + for (const page of pages) { + expect(Math.abs(page.entete - pages[0].entete), `liste ${liste} : en-tête répété`).toBeLessThanOrEqual(JEU_PX); + const bas = boite(page.contenu).bottom; + for (const ligne of page.lignes) expect(boite(ligne).bottom, `liste ${liste} : ligne dans sa page`).toBeLessThanOrEqual(bas + JEU_PX); + } + } + for (const feuille of feuilles(m)) { + expect(feuille.querySelectorAll('[data-pied]'), 'un pied par feuille').toHaveLength(1); + expect(feuille.querySelectorAll('[data-entete-liste]'), 'un en-tête par feuille').toHaveLength(1); + } + return parListe.size; +} + +describe('aucune troncature : les listes imprimées replient (§ 7.4)', () => { + test('la liste d’accueil : un nom de deux cents caractères paraît entier, sa ligne replie ; aucune marque d’abréviation', async () => { + const m = await ouvrirApercu(contexte, chargeNomLong()); + await choisirVue(m, 'accueil'); + const noms = tous(m.racine, '[data-impression] [data-nom-imprime]'); + const long = noms.find((noeud) => noeud.textContent === NOM_LONG); + expect(long, 'le nom entier dans son nœud').toBeDefined(); + const ligne = long.closest('[data-ligne-imprimee]'); + const autre = noms.find((noeud) => noeud !== long).closest('[data-ligne-imprimee]'); + expect(boite(ligne).height, 'la ligne du nom long replie').toBeGreaterThan(1.5 * boite(autre).height); + expect(boite(long).right).toBeLessThanOrEqual(boite(ligne).right + JEU_PX); + expect(dans(m, '[data-impression]').textContent).not.toContain(ABREVIATION); + }); + + test('la feuille par table : le nom affiché paraît entier, sa ligne replie ; aucune marque d’abréviation', async () => { + const charge = chargeNomLong(); + const m = await ouvrirApercu(contexte, charge); + await choisirVue(m, 'tables'); + const attendu = nomAffiche(charge.participants.find(({ id }) => id === 1)); + const noms = tous(m.racine, '[data-impression] [data-nom-imprime]'); + const longs = noms.filter((noeud) => noeud.textContent === attendu); + expect(longs.length, 'le nom entier à chaque tour où la personne est assise').toBeGreaterThan(0); + const ligne = longs[0].closest('[data-ligne-imprimee]'); + const autre = noms.find((noeud) => noeud.closest('[data-ligne-imprimee]') !== ligne && noeud.closest('[data-liste-imprimee]') !== longs[0].closest('[data-liste-imprimee]')); + expect(boite(ligne).height, 'la ligne du nom long replie').toBeGreaterThan(1.5 * boite(autre.closest('[data-ligne-imprimee]')).height); + expect(dans(m, '[data-impression]').textContent).not.toContain(ABREVIATION); + }); +}); + +describe('la liste d’accueil (§ 11.7)', () => { + test('une ligne par personne non exclue ; la grande démonstration se pagine : les feuilles sont les pages de paginer sur les hauteurs rendues, chacune sous son en-tête et son pied', async () => { + const charge = analyser(texteLivre('grande')).charge; + const m = await ouvrirApercu(contexte, charge, { section: 'participants' }); + await choisirVue(m, 'accueil'); + expect(feuilles(m).length).toBeGreaterThan(1); + expect(verifierPagination(m)).toBe(1); + const presents = charge.participants.filter(({ exclu }) => !exclu).length; + expect(tous(m.racine, '[data-impression] [data-ligne-imprimee]')).toHaveLength(presents); + expect(tous(m.racine, '[data-impression] [data-participant]')).toHaveLength(presents); + for (const feuille of feuilles(m)) { + const entete = texte(feuille.querySelector('[data-entete-liste]')); + for (let tour = 1; tour <= charge.evenement.tours; tour += 1) expect(entete).toContain(t('feuilles.tour', { tour })); + expect(texte(feuille.querySelector('[data-pied]'))).toContain(t('impression.pied.tousLesTours')); + } + }); +}); + +describe('la liste d’accueil : les colonnes de tour à la largeur de leur texte', () => { + for (const [cas, charge] of [ + ['la grande démonstration, quatre tours', () => analyser(texteLivre('grande')).charge], + ['la principale portée à six tours', () => appliquerCommande('changerTours', chargePrincipale(), { tours: 6 }).charge], + ]) { + test(`${cas} : chaque place et chaque tête de tour tient sur une ligne`, async () => { + const m = await ouvrirApercu(contexte, charge(), { section: 'participants' }); + await choisirVue(m, 'accueil'); + const cellules = cellulesDeTour(m); + expect(cellules.length).toBeGreaterThan(0); + const repliees = cellules.filter((cellule) => lignesDeTexte(cellule) !== 1).map(texte); + expect(repliees, 'cellules de tour sur plus d’une ligne').toEqual([]); + verifierPagination(m); + }); + } + + test('sous sièges attribués, des tours trop larges pour la page replient entre les mots, sur deux lignes au plus, jamais dans un mot', async () => { + const m = await ouvrirApercu(contexte, chargeSiegesAttribues(), { section: 'participants' }); + await choisirVue(m, 'accueil'); + const cellules = cellulesDeTour(m); + expect(cellules.some((cellule) => texte(cellule).includes(t('feuilles.accueil.tableSiege', { table: 1, siege: 1 })))).toBe(true); + expect(cellules.filter((cellule) => lignesDeTexte(cellule) > 2).map(texte), 'cellules de tour sur plus de deux lignes').toEqual([]); + expect(cellules.flatMap(motsCoupes), 'mots coupés').toEqual([]); + verifierPagination(m); + }); +}); + +describe('la feuille par table (§ 11.7, Q6 c)', () => { + test('une feuille par table, les tours en colonnes : chaque place listée, une place vide écrite comme telle', async () => { + const charge = appliquerCommande('creerTable', chargePrincipale(), { forme: 'ronde' }).charge; + const nouvelle = charge.tables.at(-1); + const m = await ouvrirApercu(contexte, charge); + await choisirVue(m, 'tables'); + expect(verifierPagination(m)).toBe(charge.tables.length); + const blocs = tous(m.racine, '[data-impression] [data-liste-imprimee]'); + expect(blocs.map((bloc) => Number(bloc.dataset.table)).sort((a, b) => a - b)).toEqual(charge.tables.map(({ id }) => id).sort((a, b) => a - b)); + const bloc = blocs.find((candidat) => Number(candidat.dataset.table) === nouvelle.id); + const places = charge.evenement.siegesParDefaut; + expect(tous(bloc, '[data-ligne-imprimee]')).toHaveLength(places); + const vides = tous(bloc, '[data-place-vide]'); + expect(vides).toHaveLength(places * charge.evenement.tours); + for (const vide of vides) expect(texte(vide)).toBe(t('feuilles.tables.vide')); + expect(texte(bloc)).toContain(t('feuilles.tables.titre', { table: nouvelle.numero })); + for (const feuille of feuilles(m)) expect(texte(feuille.querySelector('[data-pied]'))).toContain(t('impression.pied.tousLesTours')); + + // Les titres nommés : « hôte », pourvu, à la table 1, place 1 ; + // « animateur », non pourvu, à la table 3, place 3 — à chaque tour. + const cellules = (idTable, place) => + [...blocs.find((candidat) => Number(candidat.dataset.table) === idTable).querySelector(`[data-ligne-imprimee="${place - 1}"]`).cells].slice(1).map(texte); + const hote = cellules(1, 1); + expect(hote).toHaveLength(charge.evenement.tours); + for (const cellule of hote) { + expect(cellule).toContain('hôte'); + expect(cellule).not.toContain(t('feuilles.tables.titreNonPourvu')); + } + const animateur = cellules(3, 3); + expect(animateur).toHaveLength(charge.evenement.tours); + for (const cellule of animateur) { + expect(cellule).toContain('animateur'); + expect(cellule).toContain(t('feuilles.tables.titreNonPourvu')); + } + }); + + for (const [cas, chargeDuCas] of [ + ['en lecture', chargePrincipale], + ['sur un plan bloqué', chargeBloquee], + ]) { + test(`« Une feuille par tour », ${cas} : une feuille par table et par tour, au pied de son tour ; la bascule revient aux colonnes ; rien ne s’écrit`, async () => { + const charge = chargeDuCas(); + const m = await ouvrirApercu(contexte, charge); + expect(m.seance.etat().mode).toBe('lecture'); + const avant = m.trace.length; + await choisirVue(m, 'tables'); + const enColonnes = feuilles(m).length; + await basculerParTour(m, true); + const R = charge.evenement.tours; + expect(verifierPagination(m)).toBe(charge.tables.length * R); + const blocs = tous(m.racine, '[data-impression] [data-liste-imprimee]'); + blocs.forEach((bloc, rang) => { + const tour = (rang % R) + 1; + expect(texte(bloc.querySelector('[data-entete-liste]'))).toContain(t('feuilles.tour', { tour })); + expect(texte(bloc.closest('[data-feuille]').querySelector('[data-pied]'))).toContain(t('impression.pied.tour', { tour })); + }); + await basculerParTour(m, false); + expect(feuilles(m)).toHaveLength(enColonnes); + expect(m.trace.slice(avant), 'écritures du système simulé').toEqual([]); + }); + } + + test('« Une feuille par tour » sur un retenu plus court que la soirée : les tours qu’il porte sans filigrane, le dernier avec', async () => { + const charge = chargeRetenuCourt(); + const R = charge.evenement.tours; + expect(estBrouillon(charge, { source: 'retenu', tour: 1 })).toBe(false); + expect(estBrouillon(charge, { source: 'retenu', tour: 'tous' })).toBe(true); + const m = await ouvrirApercu(contexte, charge); + await choisirVue(m, 'tables'); + for (const feuille of feuilles(m)) expect(feuille.querySelectorAll('[data-filigrane]'), 'en colonnes').toHaveLength(1); + await basculerParTour(m, true); + const blocs = tous(m.racine, '[data-impression] [data-liste-imprimee]'); + expect(blocs).toHaveLength(charge.tables.length * R); + blocs.forEach((bloc, rang) => { + const tour = (rang % R) + 1; + const feuille = bloc.closest('[data-feuille]'); + expect(feuille.querySelectorAll('[data-filigrane]'), `tour ${tour}`).toHaveLength(tour === R ? 1 : 0); + }); + }); +}); diff --git a/src/interface/impression/listes.js b/src/interface/impression/listes.js new file mode 100644 index 0000000..75de560 --- /dev/null +++ b/src/interface/impression/listes.js @@ -0,0 +1,221 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les modèles de la liste d'accueil et de la feuille par table (§ 11.7), +// sans DOM : ce que chaque ligne imprime, lu dans l'état des places de +// chaque tour (etatPlaces) — la source que l'aperçu imprime, tour par tour. +// Le modèle rend des nombres, des identifiants et des données — noms, +// appartenances, libellés de titre — tels que la charge les porte, jamais +// abrégés : ces deux vues replient (§ 7.4). Les composants les traduisent +// et les posent. +// +// L'ordre de la liste d'accueil ne dépend pas de l'environnement (§ 15.5) : +// la clé normalisée injectée, comparée point de code par point de code, +// jamais la collation de la langue du système ; deux lignes que rien +// d'autre ne sépare, par identifiant. +import { comparerPointsDeCode } from '../../application/planche.js'; +import { nomAffiche } from '../../application/personnes.js'; +import { etatPlaces } from '../../application/places.js'; +import { estBrouillon, horodatagePlacement } from '../../application/sorties.js'; +import { feuilleDe } from './modele.js'; + +/** + * L'état des places de chaque tour de la soirée, de 1 à R, de la source que + * l'aperçu imprime : la proposition demandée — celle que porte l'état du + * premier tour, null hors de la source proposition —, sinon le retenu, + * sinon les réservations, tour par tour (etatPlaces). + * + * @param {Object} charge + * @param {number|null} proposition + * @returns {Object[]} EtatPlaces, un par tour + */ +export function etatsDesTours(charge, proposition) { + return Array.from({ length: charge.evenement.tours }, (_, rang) => etatPlaces(charge, { tour: rang + 1, proposition })); +} + +// Où chaque personne s'assied à un tour : Map participant → { table +// (numéro affiché), siege }, le siège sous sièges attribués seulement. +function placesDuTour(etat) { + const places = new Map(); + for (const { numero, chaises } of etat.tables) { + for (const { siege, occupant } of chaises) { + if (occupant !== null) places.set(occupant, { table: numero, siege: etat.siegesAttribues ? siege : null }); + } + } + return places; +} + +/** + * La liste d'accueil (§ 11.7) : une ligne par personne non exclue, triée par + * la clé de son nom, puis de son prénom, puis de son appartenance — un champ + * absent pour clé celle du texte vide —, par points de code, puis par + * identifiant. Chaque ligne porte nom, prénom et appartenance tels que la + * charge les écrit, et, pour chaque tour, la table où la personne s'assied — + * son numéro affiché —, le siège sous sièges attribués seulement (§ 11.4), + * table et siège null pour la réserve. + * + * @param {Object} charge + * @param {Object[]} etatsParTour etatsDesTours + * @param {(texte: string) => string} cle la clé normalisée (cleNormalisee) + * @returns {Array<{participant: number, nom: string, prenom: string|null, appartenance: string|null, + * places: Array<{tour: number, table: number|null, siege: number|null}>}>} + */ +export function lignesAccueil(charge, etatsParTour, cle) { + const parTour = etatsParTour.map((etat) => ({ tour: etat.tour, places: placesDuTour(etat) })); + const lignes = charge.participants + .filter(({ exclu }) => !exclu) + .map((personne) => ({ + cles: [cle(personne.nom), cle(personne.prenom ?? ''), cle(personne.appartenance ?? '')], + ligne: { + participant: personne.id, + nom: personne.nom, + prenom: personne.prenom, + appartenance: personne.appartenance, + places: parTour.map(({ tour, places }) => ({ tour, table: null, siege: null, ...places.get(personne.id) })), + }, + })); + const comparer = (a, b) => + comparerPointsDeCode(a.cles[0], b.cles[0]) || + comparerPointsDeCode(a.cles[1], b.cles[1]) || + comparerPointsDeCode(a.cles[2], b.cles[2]) || + a.ligne.participant - b.ligne.participant; + return lignes.sort(comparer).map(({ ligne }) => ligne); +} + +/** + * Le libellé d'une place de la liste d'accueil : la table, la table et le + * siège, ou la réserve. + * + * @param {{table: number|null, siege: number|null}} place + * @returns {{cle: string, details: Object}} + */ +export function libellePlace({ table, siege }) { + if (table === null) return { cle: 'feuilles.accueil.reserve', details: {} }; + if (siege === null) return { cle: 'feuilles.accueil.table', details: { table } }; + return { cle: 'feuilles.accueil.tableSiege', details: { table, siege } }; +} + +/** + * Les feuilles par table (§ 11.7, Q6 c) : par défaut une feuille par table, + * les tours de la soirée en colonnes ; sous parTour, une feuille par table + * et par tour, les tours d'une table à la suite. Les tables se rangent par + * numéro affiché, puis par identifiant. Une feuille liste chaque place de la + * table, de 1 à sa capacité, et pour chaque tour de ses colonnes ce que + * l'état des places y montre : l'occupant et son nom affiché, ou une place + * vide (null), et le titre de la place, pourvu ou non à ce tour, tel que le + * plan le montre (§ 4.1) — un titre appartient à la place, l'occupant n'en + * devient pas titulaire. + * + * @param {Object} charge + * @param {Object[]} etatsParTour etatsDesTours + * @param {{parTour?: boolean}} [options] + * @returns {Array<{table: number, numero: number, tours: number[], + * lignes: Array<{place: number, cellules: Array<{tour: number, occupant: number|null, + * nom: string|null, titre: {libelle: string, pourvu: boolean}|null}>}>}>} + * @throws {TypeError} parTour hors des booléens + */ +export function feuillesTables(charge, etatsParTour, { parTour = false } = {}) { + if (typeof parTour !== 'boolean') throw new TypeError(`feuillesTables : parTour booléen attendu, reçu ${String(parTour)}`); + const noms = new Map(charge.participants.map((personne) => [personne.id, nomAffiche(personne)])); + const tables = [...charge.tables].sort((a, b) => a.numero - b.numero || a.id - b.id); + // Les chaises de la table id à chaque tour, dans l'ordre des tours reçus. + const chaisesDe = (id) => etatsParTour.map((etat) => ({ tour: etat.tour, chaises: etat.tables.find((table) => table.id === id).chaises })); + const cellule = (tour, chaise) => + chaise === undefined || chaise.occupant === null + ? { tour, occupant: null, nom: null, titre: chaise?.titre ?? null } + : { tour, occupant: chaise.occupant, nom: noms.get(chaise.occupant), titre: chaise.titre }; + const feuille = (table, colonnes) => { + const places = Math.max(0, ...colonnes.map(({ chaises }) => chaises.length)); + return { + table: table.id, + numero: table.numero, + tours: colonnes.map(({ tour }) => tour), + lignes: Array.from({ length: places }, (_, rang) => ({ + place: rang + 1, + cellules: colonnes.map(({ tour, chaises }) => cellule(tour, chaises[rang])), + })), + }; + }; + return tables.flatMap((table) => { + const colonnes = chaisesDe(table.id); + return parTour ? colonnes.map((colonne) => feuille(table, [colonne])) : [feuille(table, colonnes)]; + }); +} + +/** + * La feuille d'une page qui ne couvre qu'un tour (Q6 c) : le pied reçu + * (feuilleDe), dont le nom de l'événement, sa date et la version restent, + * au tour de l'état, daté de l'horodatage de sa source (horodatagePlacement), + * et le filigrane de la règle du brouillon à ce tour (estBrouillon, Q5 b). + * + * @param {Object} charge + * @param {ReturnType} feuille celle de la soirée, que l'aperçu compose + * @param {Object} etat EtatPlaces du tour + * @returns {ReturnType} + */ +export function feuilleDuTour(charge, feuille, etat) { + const donnee = (nom) => feuille.elements.find(({ element }) => element === nom).donnee; + const { source, proposition, tour } = etat; + const pied = { + nom: donnee('nom'), + date: donnee('date'), + tour, + horodatage: horodatagePlacement(charge, { source, proposition }), + version: donnee('version'), + }; + return feuilleDe(pied, estBrouillon(charge, { source, tour })); +} + +// La part de la largeur que prennent les colonnes de texte de chaque vue — +// nom, prénom, appartenance ; le numéro de la place —, en pour cent, quand +// les tours se partagent le reste à parts égales. +const COLONNES_FIXES = new Map([ + ['accueil', [24, 18, 18]], + ['tables', [12]], +]); + +// La part que nom, prénom et appartenance de la liste d'accueil gardent au +// moins quand les colonnes de tour se règlent sur leur texte, en pour cent : +// au-delà, les tours se partagent le reste et leurs libellés replient. +const PLANCHER_NOMS = 100 / 3; + +// L'écart sous lequel une somme de parts égale encore sa borne : le bruit +// de l'arithmétique flottante. +const BRUIT = 1e-9; + +/** + * Les largeurs des colonnes d'une liste imprimée, en pour cent de la + * largeur du contenu, de somme 100 : celles de la vue, puis une par tour. + * Les deux vues se rendent en disposition fixe, si bien que la feuille où + * se mesurent les lignes et les pages qui les impriment replient de même. + * + * Sans partTour, les tours se partagent à parts égales ce que COLONNES_FIXES + * laisse. Avec partTour — la liste d'accueil seule —, la part de la largeur + * que le plus large libellé de place ou tête de tour prend sur une ligne + * (mesurerPlace) : chaque tour la reçoit si tous tiennent en laissant aux + * noms PLANCHER_NOMS, sinon les tours se partagent à parts égales ce que le + * plancher laisse ; les noms prennent le reste, dans les proportions de + * COLONNES_FIXES. Un tour à sa part tient sur une ligne ; sous le plancher, + * son libellé replie, entre ses mots d'abord. + * + * @param {'accueil'|'tables'} vue + * @param {number} tours le nombre de colonnes de tour, entier ≥ 1 + * @param {number|null} [partTour] en pour cent, finie, > 0 ; null par défaut + * @returns {number[]} + * @throws {RangeError} une autre vue, un nombre de tours hors du domaine ; + * une part hors du domaine, ou reçue pour la feuille par table + */ +export function largeursColonnes(vue, tours, partTour = null) { + const fixes = COLONNES_FIXES.get(vue); + if (fixes === undefined) throw new RangeError(`largeursColonnes : vue inconnue ${JSON.stringify(vue)}`); + if (!Number.isSafeInteger(tours) || tours < 1) throw new RangeError(`largeursColonnes : tours entier ≥ 1 attendu, reçu ${String(tours)}`); + const somme = fixes.reduce((a, b) => a + b, 0); + if (partTour === null) return [...fixes, ...Array.from({ length: tours }, () => (100 - somme) / tours)]; + if (vue !== 'accueil') throw new RangeError(`largeursColonnes : une part de tour pour la vue ${vue}`); + if (typeof partTour !== 'number' || !Number.isFinite(partTour) || partTour <= 0) { + throw new RangeError(`largeursColonnes : part de tour finie > 0 attendue, reçu ${String(partTour)}`); + } + const tour = tours * partTour <= 100 - PLANCHER_NOMS + BRUIT ? partTour : (100 - PLANCHER_NOMS) / tours; + const noms = 100 - tours * tour; + return [...fixes.map((part) => (part * noms) / somme), ...Array.from({ length: tours }, () => tour)]; +} diff --git a/src/interface/impression/listes.test.js b/src/interface/impression/listes.test.js new file mode 100644 index 0000000..9838225 --- /dev/null +++ b/src/interface/impression/listes.test.js @@ -0,0 +1,330 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les modèles de la liste d'accueil et de la feuille par table (§ 11.7, +// § 11.4, § 7.4, § 15.5), sans DOM : l'ordre de la liste d'accueil par la clé +// normalisée injectée, jamais localeCompare ; ce que chaque ligne dit de +// chaque tour ; les feuilles par table, les tours en colonnes ou une feuille +// par tour (Q6 c) ; le pied et le filigrane d'une feuille d'un seul tour. +// Les noms d'épreuve sont inventés, hors du réservoir des démonstrations. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { H_RETENU, personne, soiree, table } from '../../../test/fixtures/badges/cas.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { cleNormalisee } from '../../application/csv.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { feuilleDe } from './modele.js'; +import { + etatsDesTours, + feuilleDuTour, + feuillesTables, + largeursColonnes, + libellePlace, + lignesAccueil, +} from './listes.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +// Dix personnes, dont une exclue, sur deux tables de quatre, deux tours : +// Anouk Eboulis attend au tour 1, Iris Ombrelle au tour 2. Les Lacasse +// partagent leur nom ; quatre Ondine Lacasse ne se séparent que par +// l'appartenance, deux d'entre elles par l'identifiant seul. +function accueil(autres = {}) { + return soiree({ + personnes: [ + personne(1, 'Iris', 'Ombrelle', { appartenance: 'Club des Merles' }), + personne(2, 'Théo', '\u{0152}illet'), + personne(3, '\u{00C9}lise', 'Lacasse'), + personne(4, 'Ondine', 'Lacasse'), + personne(5, 'Ondine', 'Lacasse', { appartenance: 'Grisaille' }), + personne(6, 'Ondine', 'Lacasse', { appartenance: 'Club des Merles' }), + personne(7, 'Ondine', 'Lacasse', { appartenance: ' club des MERLES ' }), + personne(8, null, '\u{00C9}zard'), + personne(9, 'Rémi', 'Zinnia', { exclu: true }), + personne(10, 'Anouk', 'Eboulis'), + ], + tables: [table(1, 7, 4), table(2, 3, 4)], + plan: [ + { sieges: [[1, 2, 3, 4], [5, 6, 7, 8]], reserve: [10] }, + { sieges: [[2, 3, 4, 10], [5, 6, 7, 8]], reserve: [1] }, + ], + ...autres, + }); +} + +const etats = (charge, proposition = null) => etatsDesTours(charge, proposition); +const ids = (lignes) => lignes.map(({ participant }) => participant); +const ligneDe = (lignes, id) => lignes.find(({ participant }) => participant === id); + +describe('etatsDesTours : l’état des places de chaque tour de la soirée', () => { + test('un état par tour, de 1 à R, de la source demandée', () => { + const charge = accueil(); + const lus = etats(charge); + assert.deepEqual(lus.map(({ tour }) => tour), [1, 2]); + assert.deepEqual(lus.map(({ source }) => source), ['retenu', 'retenu']); + }); +}); + +describe('lignesAccueil : la liste d’accueil (§ 11.7)', () => { + test('triée par la clé normalisée du nom, puis du prénom, par points de code : un accent ne renvoie pas un nom après Z, Œ vient après O', () => { + const lignes = lignesAccueil(accueil(), etats(accueil()), cleNormalisee); + assert.deepEqual(ids(lignes), [10, 8, 3, 4, 6, 7, 5, 1, 2]); + // Par unités brutes, « Ézard » et « Œillet » partiraient en fin de liste. + assert.ok('\u{00C9}zard' > 'Zinnia' && '\u{0152}illet' > 'Zinnia'); + // localeCompare rangerait « œillet » avant « ombrelle » : la liste ne le suit pas. + assert.ok(cleNormalisee('\u{0152}illet').localeCompare('ombrelle', 'fr') < 0); + }); + + test('jamais localeCompare : la liste se compose quand il lève', () => { + const charge = accueil(); + const lus = etats(charge); + const original = String.prototype.localeCompare; + String.prototype.localeCompare = () => { + throw new Error('localeCompare appelé'); + }; + try { + assert.deepEqual(ids(lignesAccueil(charge, lus, cleNormalisee)), [10, 8, 3, 4, 6, 7, 5, 1, 2]); + } finally { + String.prototype.localeCompare = original; + } + }); + + test('la clé est celle qu’on injecte : une clé brute rend l’ordre des unités', () => { + const lignes = lignesAccueil(accueil(), etats(accueil()), (texte) => texte); + assert.deepEqual(ids(lignes), [10, 4, 7, 6, 5, 3, 1, 8, 2]); + }); + + test('des homonymes se départagent par l’appartenance — absente d’abord —, puis par l’identifiant', () => { + const lignes = lignesAccueil(accueil(), etats(accueil()), cleNormalisee); + const ondines = lignes.filter(({ nom, prenom }) => nom === 'Lacasse' && prenom === 'Ondine'); + assert.deepEqual(ids(ondines), [4, 6, 7, 5]); + assert.deepEqual( + ondines.map(({ appartenance }) => appartenance), + [null, 'Club des Merles', ' club des MERLES ', 'Grisaille'], + ); + }); + + test('un exclu n’a pas de ligne ; chaque autre personne en a une, avec nom, prénom et appartenance tels quels', () => { + const charge = accueil(); + const lignes = lignesAccueil(charge, etats(charge), cleNormalisee); + assert.equal(ligneDe(lignes, 9), undefined); + assert.equal(lignes.length, 9); + assert.deepEqual( + { nom: ligneDe(lignes, 8).nom, prenom: ligneDe(lignes, 8).prenom, appartenance: ligneDe(lignes, 8).appartenance }, + { nom: '\u{00C9}zard', prenom: null, appartenance: null }, + ); + // Exclue, la personne ne revient pas même quand un état la nommerait. + const exclue = accueil(); + exclue.participants[0].exclu = true; + assert.equal(ligneDe(lignesAccueil(exclue, etats(exclue), cleNormalisee), 1), undefined); + }); + + test('par tour, le numéro de la table ; la réserve, sans table ni siège', () => { + const charge = accueil(); + const lignes = lignesAccueil(charge, etats(charge), cleNormalisee); + assert.deepEqual(ligneDe(lignes, 1).places, [ + { tour: 1, table: 7, siege: null }, + { tour: 2, table: null, siege: null }, + ]); + assert.deepEqual(ligneDe(lignes, 10).places, [ + { tour: 1, table: null, siege: null }, + { tour: 2, table: 7, siege: null }, + ]); + assert.deepEqual(ligneDe(lignes, 5).places, [ + { tour: 1, table: 3, siege: null }, + { tour: 2, table: 3, siege: null }, + ]); + }); + + test('le siège seulement sous sièges attribués, compté chaises vides comprises', () => { + const attribuee = soiree({ + personnes: [personne(1, 'Iris', 'Ombrelle'), personne(2, 'Théo', 'Pervenche')], + tables: [table(1, 7, 3)], + plan: [{ sieges: [[1, null, 2]] }], + siegesAttribues: true, + }); + const lignes = lignesAccueil(attribuee, etats(attribuee), cleNormalisee); + assert.deepEqual(ligneDe(lignes, 1).places, [{ tour: 1, table: 7, siege: 1 }]); + assert.deepEqual(ligneDe(lignes, 2).places, [{ tour: 1, table: 7, siege: 3 }]); + const libre = soiree({ + personnes: [personne(1, 'Iris', 'Ombrelle'), personne(2, 'Théo', 'Pervenche')], + tables: [table(1, 7, 3)], + plan: [{ sieges: [[1, 2]] }], + }); + for (const ligne of lignesAccueil(libre, etats(libre), cleNormalisee)) { + assert.deepEqual(ligne.places, [{ tour: 1, table: 7, siege: null }], String(ligne.participant)); + } + }); + + test('chaque place a son libellé : la table, la table et le siège, ou la réserve', () => { + assert.deepEqual(libellePlace({ tour: 1, table: 7, siege: null }), { cle: 'feuilles.accueil.table', details: { table: 7 } }); + assert.deepEqual(libellePlace({ tour: 2, table: 7, siege: 3 }), { cle: 'feuilles.accueil.tableSiege', details: { table: 7, siege: 3 } }); + assert.deepEqual(libellePlace({ tour: 1, table: null, siege: null }), { cle: 'feuilles.accueil.reserve', details: {} }); + assert.equal(t('feuilles.accueil.tableSiege', { table: 7, siege: 3 }), 'Table 7, siège 3'); + assert.equal(t('feuilles.accueil.reserve'), 'Réserve'); + }); + + test('une soirée sans personne donne une liste vide', () => { + const vide = soiree({ personnes: [], tables: [table(1, 7, 3)], plan: [{ sieges: [[]] }] }); + assert.deepEqual(lignesAccueil(vide, etats(vide), cleNormalisee), []); + }); +}); + +// Trois personnes à la table 7, sièges attribués, deux tours, une chaise +// vide au siège 2 du tour 1 ; Iris Ombrelle réservée au siège 1 pour la +// soirée, qui porte le titre « animateur » ; le siège 2 porte « hôte », que +// rien ne pourvoit. La table 3, vide, vient avant elle par son numéro. +function titree() { + return soiree({ + personnes: [personne(1, 'Iris', 'Ombrelle'), personne(2, 'Théo', 'Pervenche'), personne(3, 'Ondine', 'Lacasse')], + tables: [table(1, 7, 3), table(2, 3, 2)], + plan: [ + { sieges: [[1, null, 2], []], reserve: [3] }, + { sieges: [[1, 3, 2], []] }, + ], + siegesAttribues: true, + reservations: [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }], + titres: [ + { table: 1, siege: 1, libelle: 'animateur' }, + { table: 1, siege: 2, libelle: 'hôte' }, + ], + }); +} + +const vide = (tour) => ({ tour, occupant: null, nom: null, titre: null }); + +describe('feuillesTables : la feuille par table (§ 11.7, Q6 c)', () => { + test('par défaut une feuille par table, par numéro croissant, les tours en colonnes ; chaque siège listé, une place vide écrite comme telle', () => { + const charge = titree(); + const feuilles = feuillesTables(charge, etats(charge)); + assert.deepEqual(feuilles.map(({ table: id, numero, tours }) => ({ id, numero, tours })), [ + { id: 2, numero: 3, tours: [1, 2] }, + { id: 1, numero: 7, tours: [1, 2] }, + ]); + assert.deepEqual(feuilles[0].lignes, [ + { place: 1, cellules: [vide(1), vide(2)] }, + { place: 2, cellules: [vide(1), vide(2)] }, + ]); + const [siege1, siege2, siege3] = feuilles[1].lignes; + assert.deepEqual(siege1, { + place: 1, + cellules: [ + { tour: 1, occupant: 1, nom: 'Iris Ombrelle', titre: { libelle: 'animateur', pourvu: true } }, + { tour: 2, occupant: 1, nom: 'Iris Ombrelle', titre: { libelle: 'animateur', pourvu: true } }, + ], + }); + assert.deepEqual(siege2, { + place: 2, + cellules: [ + { tour: 1, occupant: null, nom: null, titre: { libelle: 'hôte', pourvu: false } }, + { tour: 2, occupant: 3, nom: 'Ondine Lacasse', titre: { libelle: 'hôte', pourvu: false } }, + ], + }); + assert.deepEqual(siege3.cellules.map(({ nom }) => nom), ['Théo Pervenche', 'Théo Pervenche']); + }); + + test('sous parTour, une feuille par table et par tour, la table d’abord ; chacune une seule colonne', () => { + const charge = titree(); + const feuilles = feuillesTables(charge, etats(charge), { parTour: true }); + assert.deepEqual(feuilles.map(({ table: id, tours }) => [id, tours]), [ + [2, [1]], + [2, [2]], + [1, [1]], + [1, [2]], + ]); + assert.deepEqual(feuilles[3].lignes.map(({ place, cellules }) => [place, cellules.map(({ occupant }) => occupant)]), [ + [1, [1]], + [2, [3]], + [3, [2]], + ]); + }); + + test('un exclu n’occupe aucune place : la sienne s’écrit vide', () => { + const charge = titree(); + charge.participants[1].exclu = true; + const [, table7] = feuillesTables(charge, etats(charge)); + assert.deepEqual(table7.lignes[2].cellules, [vide(1), vide(2)]); + }); + + test('sans tables, aucune feuille', () => { + const charge = soiree({ personnes: [personne(1, 'Iris', 'Ombrelle')], tables: [], plan: null }); + assert.deepEqual(feuillesTables(charge, etats(charge)), []); + }); + + test('parTour hors des booléens lève TypeError', () => { + const charge = titree(); + assert.throws(() => feuillesTables(charge, etats(charge), { parTour: 'oui' }), TypeError); + }); +}); + +describe('feuilleDuTour : le pied et le filigrane d’une feuille d’un seul tour (Q5 b, Q6 c)', () => { + // La soirée titree retenue à un tour, puis portée à deux : le retenu porte + // le tour 1, pas le tour 2, que les réservations seules décrivent. + function retenuCourt() { + const charge = titree(); + charge.retenu.tours = 1; + charge.retenu.placement = charge.retenu.placement.slice(0, 1); + return charge; + } + const PIED = Object.freeze({ nom: 'Soirée des Lucioles', date: '2031-05-17', tour: 'tous', horodatage: H_RETENU, version: 'v' }); + + test('le tour porté : son numéro, l’horodatage du retenu, sans filigrane ; le tour que le retenu ne porte pas : non daté, BROUILLON', () => { + const charge = retenuCourt(); + const lus = etats(charge); + assert.deepEqual(lus.map(({ source }) => source), ['retenu', 'reservations']); + const feuille = feuilleDe(PIED, true); + assert.deepEqual(feuilleDuTour(charge, feuille, lus[0]), feuilleDe({ ...PIED, tour: 1 }, false)); + assert.deepEqual(feuilleDuTour(charge, feuille, lus[1]), feuilleDe({ ...PIED, tour: 2, horodatage: null }, true)); + }); + + test('une date absente reste absente ; nom et version restent ceux de la feuille reçue', () => { + const charge = retenuCourt(); + const sansDate = { ...PIED, date: null, nom: 'Club des Merles', version: 'documentation' }; + assert.deepEqual(feuilleDuTour(charge, feuilleDe(sansDate, false), etats(charge)[0]), feuilleDe({ ...sansDate, tour: 1 }, false)); + }); +}); + +describe('largeursColonnes : les colonnes d’une liste imprimée, en pour cent', () => { + test('la liste d’accueil : nom, prénom, appartenance, puis un tour par colonne ; la feuille par table : la place, puis les tours', () => { + assert.deepEqual(largeursColonnes('accueil', 2), [24, 18, 18, 20, 20]); + assert.deepEqual(largeursColonnes('tables', 1), [12, 88]); + assert.deepEqual(largeursColonnes('tables', 4), [12, 22, 22, 22, 22]); + for (const [vue, tours] of [['accueil', 1], ['accueil', 7], ['tables', 3]]) { + const somme = largeursColonnes(vue, tours).reduce((a, b) => a + b, 0); + assert.ok(Math.abs(somme - 100) < 1e-9, `${vue} ${tours} : ${somme}`); + } + }); + + test('la liste d’accueil, la part mesurée d’un tour reçue : chaque tour la prend, les noms se partagent le reste dans leurs proportions', () => { + const largeurs = largeursColonnes('accueil', 4, 11); + assert.deepEqual(largeurs.slice(3), [11, 11, 11, 11]); + const noms = largeurs.slice(0, 3); + for (const [rang, attendue] of [22.4, 16.8, 16.8].entries()) assert.ok(Math.abs(noms[rang] - attendue) < 1e-9, `${rang} : ${noms[rang]}`); + assert.ok(Math.abs(largeursColonnes('accueil', 1, 9)[0] - 91 * 0.4) < 1e-9); + }); + + test('la liste d’accueil, des tours qui ne tiennent pas : les noms gardent leur plancher d’un tiers, les tours se partagent le reste', () => { + const largeurs = largeursColonnes('accueil', 6, 12); + for (const part of largeurs.slice(3)) assert.ok(Math.abs(part - 200 / 18) < 1e-9, String(part)); + assert.ok(Math.abs(largeurs.slice(0, 3).reduce((a, b) => a + b, 0) - 100 / 3) < 1e-9); + // Juste au plancher, les tours tiennent encore. + assert.deepEqual(largeursColonnes('accueil', 2, 100 / 3).slice(3), [100 / 3, 100 / 3]); + // Une part plus large que la page ne lève pas : le plancher la borne. + assert.deepEqual(largeursColonnes('accueil', 1, 150).slice(3), [100 - 100 / 3]); + for (const [tours, part] of [[4, 11], [6, 12], [3, 40], [1, 0.5], [1, 150]]) { + const somme = largeursColonnes('accueil', tours, part).reduce((a, b) => a + b, 0); + assert.ok(Math.abs(somme - 100) < 1e-9, `${tours} ${part} : ${somme}`); + } + }); + + test('une vue, un nombre de tours ou une part hors de leur domaine lèvent RangeError', () => { + assert.throws(() => largeursColonnes('plan', 1), RangeError); + assert.throws(() => largeursColonnes('accueil', 0), RangeError); + assert.throws(() => largeursColonnes('tables', 1.5), RangeError); + for (const part of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, '10']) { + assert.throws(() => largeursColonnes('accueil', 2, part), RangeError, String(part)); + } + assert.throws(() => largeursColonnes('tables', 2, 10), RangeError); + }); +}); diff --git a/src/interface/impression/pagination.js b/src/interface/impression/pagination.js new file mode 100644 index 0000000..e6a8c45 --- /dev/null +++ b/src/interface/impression/pagination.js @@ -0,0 +1,128 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La pagination des listes imprimées (§ 11.7) : la liste d'accueil et la +// feuille par table ne tronquent jamais, elles replient (§ 7.4) ; la hauteur +// d'une ligne dépend donc de son texte, et se mesure au rendu. Ce module +// range des hauteurs mesurées en pages, sans DOM, dans une seule unité au +// choix de l'appelant — px CSS pour les vues. Chaque page répète l'en-tête +// de sa liste. mesurerListes lit, sur une feuille rendue hors de la vue, ce +// que la pagination reçoit. + +// L'écart qu'une somme de hauteurs peut avoir avec la hauteur utile et y +// tenir encore : le bruit de l'arithmétique flottante (0,1 + 0,2 dépasse 0,3 +// de 5,6 × 10⁻¹⁷), loin sous le centième que la pagination distingue. +const BRUIT = 1e-9; + +const hauteurValide = (valeur) => typeof valeur === 'number' && Number.isFinite(valeur) && valeur >= 0; + +/** + * Les pages d'une liste : chacune porte l'en-tête, puis les lignes de debut + * (compris) à fin (exclu), autant qu'il en tient — en-tête et lignes de + * somme au plus hauteurUtile. Les lignes se suivent dans l'ordre reçu, + * chacune sur une page et une seule. Une ligne plus haute que ce que + * l'en-tête laisse d'une page est seule sur la sienne, et seule à porter + * seule vrai. Une liste vide donne une page, son en-tête seul. + * + * @param {number[]} hauteurs une par ligne, finies, ≥ 0 + * @param {{hauteurUtile: number, hauteurEntete: number}} page + * @returns {Array<{debut: number, fin: number, seule: boolean}>} + * @throws {TypeError} des hauteurs qui ne sont pas une liste + * @throws {RangeError} une hauteur hors du domaine ; une hauteur utile ≤ 0 ; + * un en-tête négatif, ou qui ne laisse aucune place + */ +export function paginer(hauteurs, { hauteurUtile, hauteurEntete }) { + if (!Array.isArray(hauteurs)) throw new TypeError('paginer : une liste de hauteurs attendue'); + hauteurs.forEach((hauteur, rang) => { + if (!hauteurValide(hauteur)) throw new RangeError(`paginer : hauteur ${rang} hors du domaine, reçu ${String(hauteur)}`); + }); + if (!hauteurValide(hauteurUtile) || hauteurUtile === 0) { + throw new RangeError(`paginer : hauteur utile > 0 attendue, reçu ${String(hauteurUtile)}`); + } + if (!hauteurValide(hauteurEntete) || hauteurEntete >= hauteurUtile) { + throw new RangeError(`paginer : en-tête de ${String(hauteurEntete)} sur une hauteur utile de ${hauteurUtile}`); + } + const plafond = hauteurUtile + BRUIT; + const pages = []; + let debut = 0; + do { + let fin = debut; + let somme = hauteurEntete; + while (fin < hauteurs.length && somme + hauteurs[fin] <= plafond) { + somme += hauteurs[fin]; + fin += 1; + } + const seule = fin === debut && fin < hauteurs.length; + if (seule) fin += 1; + pages.push({ debut, fin, seule }); + debut = fin; + } while (debut < hauteurs.length); + return pages; +} + +/** + * Les pages de plusieurs listes bout à bout — une feuille par table, ou par + * table et par tour — : chaque liste commence une page et se pagine sous + * son propre en-tête (paginer). rang numérote les pages du document, de 0 ; + * feuille est l'indice de la liste. + * + * @param {Array<{entete: number, lignes: number[]}>} feuilles + * @param {number} hauteurUtile + * @returns {Array<{rang: number, feuille: number, debut: number, fin: number, seule: boolean}>} + */ +export function paginerFeuilles(feuilles, hauteurUtile) { + const pages = []; + feuilles.forEach(({ entete, lignes }, feuille) => { + for (const page of paginer(lignes, { hauteurUtile, hauteurEntete: entete })) { + pages.push({ rang: pages.length, feuille, ...page }); + } + }); + return pages; +} + +/** + * Ce que la pagination reçoit, lu sur une zone de contenu rendue : sa + * hauteur, la hauteur utile ; et pour chaque liste [data-liste-imprimee], + * dans l'ordre du document, son en-tête — du haut de la liste au haut de + * son corps de tableau : titre et têtes de colonnes — et la hauteur de + * chaque ligne de ce corps. Les mesures sont en px CSS, celles de la boîte + * rendue. + * + * @param {Element} contenu la zone [data-contenu] d'une feuille + * @returns {{utile: number, feuilles: Array<{entete: number, lignes: number[]}>}} + */ +export function mesurerListes(contenu) { + const feuilles = [...contenu.querySelectorAll('[data-liste-imprimee]')].map((liste) => { + const corps = liste.querySelector('tbody'); + return { + entete: corps.getBoundingClientRect().top - liste.getBoundingClientRect().top, + lignes: [...corps.rows].map((ligne) => ligne.getBoundingClientRect().height), + }; + }); + return { utile: contenu.getBoundingClientRect().height, feuilles }; +} + +// Ce qu'une colonne de tour reçoit au-delà de la largeur mesurée de son +// libellé, en px : les largeurs en pour cent s'arrondissent à l'unité de +// mise en page, et une colonne plus étroite d'une fraction de pixel que son +// texte le replie. +const ARRONDI_PX = 1; + +/** + * La part de la largeur d'une liste que prend, sur une ligne, son plus + * large libellé de colonne de tour, en pour cent (largeursColonnes) : la + * largeur rendue de la sonde — un tableau d'une colonne, à la largeur de + * son contenu sans repli, qui porte chaque libellé de place et chaque tête + * de tour dans les cellules de la liste —, plus ARRONDI_PX, sur celle du + * tableau de la liste. + * + * @param {Element} sonde + * @param {Element} tableau le tableau de la liste, à sa largeur d'impression + * @returns {number} + * @throws {RangeError} un tableau sans largeur + */ +export function mesurerPlace(sonde, tableau) { + const largeur = tableau.getBoundingClientRect().width; + if (!(largeur > 0)) throw new RangeError(`mesurerPlace : tableau de largeur ${String(largeur)}`); + return (100 * (sonde.getBoundingClientRect().width + ARRONDI_PX)) / largeur; +} diff --git a/src/interface/impression/pagination.test.js b/src/interface/impression/pagination.test.js new file mode 100644 index 0000000..7385799 --- /dev/null +++ b/src/interface/impression/pagination.test.js @@ -0,0 +1,131 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La pagination des listes imprimées (§ 11.7), sans DOM : des hauteurs +// mesurées, la hauteur utile d'une page et celle de l'en-tête que chaque +// page répète, et les pages qu'elles donnent — aux bords exacts, une ligne +// plus haute qu'une page, une liste vide, plusieurs feuilles bout à bout. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { mesurerPlace, paginer, paginerFeuilles } from './pagination.js'; + +describe('paginer : les lignes d’une liste en pages', () => { + test('une somme exactement égale à la hauteur utile, en-tête compris, tient sur une page ; + 0,01 en donne deux', () => { + const options = { hauteurUtile: 100, hauteurEntete: 10 }; + assert.deepEqual(paginer([25.5, 30.25, 34.25], options), [{ debut: 0, fin: 3, seule: false }]); + assert.deepEqual(paginer([25.5, 30.25, 34.26], options), [ + { debut: 0, fin: 2, seule: false }, + { debut: 2, fin: 3, seule: false }, + ]); + }); + + test('le bruit de l’arithmétique flottante ne pousse pas une ligne sur la page suivante : 0,1 + 0,2 tient dans 0,3', () => { + assert.ok(0.1 + 0.2 > 0.3); + assert.deepEqual(paginer([0.1, 0.2], { hauteurUtile: 0.3, hauteurEntete: 0 }), [{ debut: 0, fin: 2, seule: false }]); + }); + + test('l’en-tête se répète sur chaque page : chacune le retranche de sa hauteur utile', () => { + const pages = paginer([30, 30, 30, 30, 30, 30, 30], { hauteurUtile: 100, hauteurEntete: 10 }); + assert.deepEqual(pages, [ + { debut: 0, fin: 3, seule: false }, + { debut: 3, fin: 6, seule: false }, + { debut: 6, fin: 7, seule: false }, + ]); + // Sans en-tête, trois lignes de 30 laissent la place d'une quatrième de 10. + assert.deepEqual(paginer([30, 30, 30, 10], { hauteurUtile: 100, hauteurEntete: 0 }), [{ debut: 0, fin: 4, seule: false }]); + assert.deepEqual(paginer([30, 30, 30, 10], { hauteurUtile: 100, hauteurEntete: 10 }), [ + { debut: 0, fin: 3, seule: false }, + { debut: 3, fin: 4, seule: false }, + ]); + // La deuxième page retranche aussi l'en-tête : deux lignes de 50 n'y + // tiennent pas ensemble, quand elles rempliraient une page sans lui. + assert.deepEqual(paginer([45, 45, 50, 50], { hauteurUtile: 100, hauteurEntete: 10 }), [ + { debut: 0, fin: 2, seule: false }, + { debut: 2, fin: 3, seule: false }, + { debut: 3, fin: 4, seule: false }, + ]); + }); + + test('une ligne plus haute qu’une page est seule sur la sienne, et seule à porter seule vrai', () => { + const pages = paginer([20, 95, 20, 20], { hauteurUtile: 100, hauteurEntete: 10 }); + assert.deepEqual(pages, [ + { debut: 0, fin: 1, seule: false }, + { debut: 1, fin: 2, seule: true }, + { debut: 2, fin: 4, seule: false }, + ]); + // Une ligne qui tient juste n'est pas signalée, même seule sur sa page. + assert.deepEqual(paginer([20, 90], { hauteurUtile: 100, hauteurEntete: 10 }), [ + { debut: 0, fin: 1, seule: false }, + { debut: 1, fin: 2, seule: false }, + ]); + assert.deepEqual(paginer([200], { hauteurUtile: 100, hauteurEntete: 10 }), [{ debut: 0, fin: 1, seule: true }]); + }); + + test('une liste vide donne une page, son en-tête seul ; une ligne, une page', () => { + assert.deepEqual(paginer([], { hauteurUtile: 100, hauteurEntete: 10 }), [{ debut: 0, fin: 0, seule: false }]); + assert.deepEqual(paginer([5], { hauteurUtile: 100, hauteurEntete: 10 }), [{ debut: 0, fin: 1, seule: false }]); + }); + + test('chaque ligne est sur une page et une seule, dans l’ordre, et aucune page ne dépasse sa hauteur utile', () => { + const hauteurs = Array.from({ length: 257 }, (_, i) => 4 + ((i * 7) % 11)); + const options = { hauteurUtile: 120, hauteurEntete: 13 }; + const pages = paginer(hauteurs, options); + assert.equal(pages[0].debut, 0); + assert.equal(pages.at(-1).fin, hauteurs.length); + for (let i = 1; i < pages.length; i += 1) assert.equal(pages[i].debut, pages[i - 1].fin); + for (const { debut, fin } of pages) { + const somme = hauteurs.slice(debut, fin).reduce((a, b) => a + b, options.hauteurEntete); + assert.ok(somme <= options.hauteurUtile, `${debut}..${fin} : ${somme}`); + // La ligne suivante n'y tenait pas. + if (fin < hauteurs.length) assert.ok(somme + hauteurs[fin] > options.hauteurUtile, `${debut}..${fin} pouvait prendre ${fin}`); + } + }); + + test('des hauteurs ou des options hors de leur domaine lèvent RangeError ; un en-tête qui remplit la page aussi', () => { + const options = { hauteurUtile: 100, hauteurEntete: 10 }; + for (const hauteurs of [[-1], [Number.NaN], [Number.POSITIVE_INFINITY], ['3']]) { + assert.throws(() => paginer(hauteurs, options), RangeError, JSON.stringify(hauteurs)); + } + assert.throws(() => paginer('abc', options), TypeError); + for (const fautives of [{ hauteurUtile: 0, hauteurEntete: 0 }, { hauteurUtile: 100, hauteurEntete: -1 }, { hauteurUtile: 100, hauteurEntete: 100 }, { hauteurUtile: Number.NaN, hauteurEntete: 0 }]) { + assert.throws(() => paginer([1], fautives), RangeError, JSON.stringify(fautives)); + } + }); +}); + +describe('paginerFeuilles : plusieurs listes, chacune sur ses propres pages', () => { + test('chaque feuille commence une page ; ses pages se suivent, son en-tête retranché de chacune', () => { + const pages = paginerFeuilles( + [ + { entete: 10, lignes: [30, 30, 30, 30] }, + { entete: 20, lignes: [] }, + { entete: 5, lignes: [50, 45] }, + ], + 100, + ); + assert.deepEqual(pages, [ + { rang: 0, feuille: 0, debut: 0, fin: 3, seule: false }, + { rang: 1, feuille: 0, debut: 3, fin: 4, seule: false }, + { rang: 2, feuille: 1, debut: 0, fin: 0, seule: false }, + { rang: 3, feuille: 2, debut: 0, fin: 2, seule: false }, + ]); + }); + + test('aucune feuille, aucune page', () => { + assert.deepEqual(paginerFeuilles([], 100), []); + }); +}); + +describe('mesurerPlace : la part de la largeur d’une liste que prend son plus large libellé de tour', () => { + // Un élément réduit à sa boîte rendue, de la largeur donnée en px. + const boite = (width) => ({ getBoundingClientRect: () => ({ width }) }); + + test('la largeur de la sonde, plus un pixel d’arrondi, sur celle du tableau, en pour cent', () => { + assert.equal(mesurerPlace(boite(79), boite(800)), 10); + assert.equal(mesurerPlace(boite(0), boite(400)), 0.25); + }); + + test('un tableau sans largeur lève RangeError', () => { + assert.throws(() => mesurerPlace(boite(10), boite(0)), RangeError); + }); +}); diff --git a/src/interface/libelles.js b/src/interface/libelles.js index 6af57f4..ef05cd1 100644 --- a/src/interface/libelles.js +++ b/src/interface/libelles.js @@ -15,6 +15,7 @@ import { TABLE as COMMUNS } from './libelles/communs.js'; import { TABLE as COQUILLE } from './libelles/coquille.js'; import { TABLE as DIAGNOSTIC } from './libelles/diagnostic.js'; import { TABLE as EVENEMENT } from './libelles/evenement.js'; +import { TABLE as FEUILLES } from './libelles/feuilles.js'; import { TABLE as GENERATION } from './libelles/generation.js'; import { TABLE as IMPORT } from './libelles/import.js'; import { TABLE as IMPRESSION } from './libelles/impression.js'; @@ -31,6 +32,7 @@ export const TABLES_INTERFACE = Object.freeze([ COQUILLE, DIAGNOSTIC, EVENEMENT, + FEUILLES, GENERATION, IMPORT, IMPRESSION, diff --git a/src/interface/libelles.test.js b/src/interface/libelles.test.js index 7d4a780..c0ac220 100644 --- a/src/interface/libelles.test.js +++ b/src/interface/libelles.test.js @@ -65,11 +65,11 @@ async function tablesDuDossier() { } describe('libellés : les tables des écrans (§ 14.6)', () => { - test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, treize, par ordre alphabétique', async () => { + test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, quatorze, par ordre alphabétique', async () => { const tables = await tablesDuDossier(); - assert.equal(tables.length, 13); + assert.equal(tables.length, 14); assert.deepEqual(tables.map(({ table }) => table), [ - 'accueil', 'communs', 'coquille', 'diagnostic', 'evenement', 'generation', + 'accueil', 'communs', 'coquille', 'diagnostic', 'evenement', 'feuilles', 'generation', 'import', 'impression', 'participants', 'plan', 'propositions', 'raccourcis', 'tables', ]); for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table); diff --git a/src/interface/libelles/feuilles.js b/src/interface/libelles/feuilles.js new file mode 100644 index 0000000..5587aba --- /dev/null +++ b/src/interface/libelles/feuilles.js @@ -0,0 +1,28 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés de la liste d'accueil et de la feuille par table imprimées +// (§ 11.7) : titres, têtes de colonnes, places. Chaque clé commence par +// « feuilles. ». Un numéro de table, de siège ou de tour entre dans le +// texte ; une donnée de la charge — nom, prénom, appartenance, libellé d'un +// titre — n'entre dans aucun : la vue la pose dans son propre nœud. +export const TABLE = new Map([ + // --- Les têtes de colonnes communes ---------------------------------------- + ['feuilles.tour', ({ tour }) => `Tour ${tour}`], + + // --- La liste d'accueil ------------------------------------------------------ + ['feuilles.accueil.titre', "Liste d'accueil"], + ['feuilles.accueil.nom', 'Nom'], + ['feuilles.accueil.prenom', 'Prénom'], + ['feuilles.accueil.appartenance', 'Appartenance'], + ['feuilles.accueil.table', ({ table }) => `Table ${table}`], + ['feuilles.accueil.tableSiege', ({ table, siege }) => `Table ${table}, siège ${siege}`], + ['feuilles.accueil.reserve', 'Réserve'], + + // --- La feuille par table ---------------------------------------------------- + ['feuilles.tables.titre', ({ table }) => `Table ${table}`], + ['feuilles.tables.place', 'Place'], + ['feuilles.tables.vide', 'Place vide'], + ['feuilles.tables.titreNonPourvu', '(titre non pourvu)'], + ['feuilles.tables.aucune', 'Aucune table'], +]); diff --git a/test/navigateur/designations.js b/test/navigateur/designations.js index c7f2a62..0c67b54 100644 --- a/test/navigateur/designations.js +++ b/test/navigateur/designations.js @@ -68,6 +68,7 @@ const ENTREES = [ ['data-enonce', 'un énoncé : diagnostic, ou défaut des tables', null], ['data-enregistre', 'import : le chemin des refus enregistrés', ['']], ['data-entete', 'import : la ligne d’en-tête lue', ['']], + ['data-entete-liste', 'aperçu : l’en-tête d’une liste imprimée — titre et têtes de colonnes —, répété sur chaque feuille', ['']], ['data-estimation', 'génération : l’estimation de la durée', ['']], ['data-etat', 'événement : un état du plan dans la tête', null], ['data-etat-table', 'tables : l’état d’une table', ['']], @@ -97,8 +98,10 @@ const ENTREES = [ ['data-legende-pastille', 'plan : la pastille de la légende', ['']], ['data-legende-regle', 'plan : une entrée de la légende, par sa règle', null], ['data-ligne', 'import : une ligne de l’aperçu, par son rang', null], + ['data-ligne-imprimee', 'aperçu : une ligne d’une liste imprimée, par son rang dans la liste', null], ['data-lignes', 'import : le tableau des lignes', ['']], ['data-liste', 'plan : la liste de noms d’une table', null], + ['data-liste-imprimee', 'aperçu : une liste imprimée — l’accueil, ou une table —, par son rang dans le document', null], ['data-liste-travail', 'plan : une liste de travail', ['pressentis', 'titres']], ['data-marque', 'une marque porteuse d’information, par son nom au registre', null], ['data-meilleure', 'la meilleure proposition : accueil ou génération', ['']], @@ -113,6 +116,7 @@ const ENTREES = [ ['data-motif', 'import : le motif d’une ligne refusée', ['']], ['data-multiligne', 'import : une ligne sur plusieurs lignes de texte', null], ['data-niveau', 'participants : le niveau d’un groupe ou d’un sélecteur', null], + ['data-nom-imprime', 'aperçu : le nom entier d’une personne sur une liste imprimée', ['']], ['data-nombre', 'import : un nombre affiché', ['']], ['data-ordre', 'propositions : la phrase de l’ordre', ['']], ['data-orthographe', 'import : une orthographe d’un groupe', ['']], @@ -125,6 +129,7 @@ const ENTREES = [ ['data-phrase', 'une phrase : diagnostic ou défaut des tables', ['']], ['data-pied', 'aperçu : le pied d’une feuille imprimée', ['']], ['data-place-fixe', 'événement : la place fixe du cadre de mode', ['']], + ['data-place-vide', 'aperçu : une place vide de la feuille par table', ['']], ['data-plan', 'plan : le du plan', ['']], ['data-poignee', 'plan : la poignée d’une table', null], ['data-proposition', 'propositions : une ligne, par son identifiant', null],