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}
+
+
+
+ | {t('feuilles.tables.place')} |
+ {#each liste.tours as tour (tour)}
+ {t('feuilles.tour', { tour })} |
+ {/each}
+
+
+
+ {#each liste.lignes.slice(debut, fin) as ligne, rang (ligne.place)}
+
+ | {ligne.place} |
+ {#each ligne.cellules as cellule (cellule.tour)}
+
+ {#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}
+ |
+ {/each}
+
+ {/each}
+
+
+
+{/snippet}
+
+{#if bandeMm === null}
+
+{:else if pages === null}
+
+
+ {#snippet contenu()}
+ {#each listes as liste, rangListe (rangListe)}
+ {@render blocTable(liste, rangListe, 0, liste.lignes.length)}
+ {/each}
+ {/snippet}
+
+
+{: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}
+
+
+
+ | {t('feuilles.accueil.nom')} |
+ {t('feuilles.accueil.prenom')} |
+ {t('feuilles.accueil.appartenance')} |
+ {#each tours as tour (tour)}
+ {t('feuilles.tour', { tour })} |
+ {/each}
+
+
+
+ {#each lignes.slice(debut, fin) as ligne, rang (ligne.participant)}
+
+ | {ligne.nom} |
+
+ {#if ligne.prenom !== null}{ligne.prenom}{/if}
+ |
+
+ {#if ligne.appartenance !== null}{ligne.appartenance}{/if}
+ |
+ {#each ligne.places as place (place.tour)}
+ {@const libelle = libellePlace(place)}
+ {t(libelle.cle, libelle.details)} |
+ {/each}
+
+ {/each}
+
+
+
+{/snippet}
+
+{#if bandeMm === null}
+
+{:else if pages === null}
+
+
+ {#snippet contenu()}
+ {@render liste(0, lignes.length)}
+
+
+ {#each tours as tour (tour)}
+ | {t('feuilles.tour', { tour })} |
+ {/each}
+ {#each libelles as libelle (libelle)}
+ | {libelle} |
+ {/each}
+
+
+ {/snippet}
+
+
+{: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