[ADD] print: welcome list and table sheets, paginated with headers

The welcome list sorts names by their normalised key and gives each
round a column whose width comes from the longest seat it holds; the
table sheet puts the rounds in columns, or one sheet per table and
round under « Une feuille par tour ». Rows are measured and paginated
with the header repeated, never split across pages; names wrap
instead of being cut. Read-only and locked plans print the same.
Checked: node 3753, browser 730, node-long 101 from the index alone.

--- FR ---

[ADD] impression : liste d'accueil et feuilles par table, paginées

La liste d'accueil trie les noms par leur clé normalisée et donne à
chaque tour une colonne large de la plus longue place qu'elle porte ;
la feuille par table met les tours en colonnes, ou une feuille par
table et par tour sous « Une feuille par tour ». Les lignes sont
mesurées et paginées sous l'en-tête répété, jamais coupées entre deux
pages ; les noms passent à la ligne plutôt que d'être tronqués. Un plan
en lecture ou verrouillé s'imprime de même.
Vérifié : node 3753, navigateur 730, node-long 101 depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-10 11:07:03 -04:00
parent e5d9026b2a
commit 62f8406d78
11 changed files with 1564 additions and 23 deletions

View file

@ -2,33 +2,202 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les feuilles par table imprimées (§ 11.7), dans leur forme minimale :
// une feuille en portrait, titrée, sans table, qui porte le pied de la
// soirée ; l'option parTour de l'aperçu ne la change pas.
// Les feuilles par table imprimées (§ 11.7, Q6 c) : par défaut une feuille
// par table, les tours de la soirée en colonnes ; sous l'option « Une
// feuille par tour » de l'aperçu (options.parTour), une feuille par table
// et par tour. Chaque feuille liste chaque place de la table : l'occupant
// de chaque tour, ou la place vide écrite comme telle, et le titre de la
// place, « non pourvu » quand rien ne le pourvoit à ce tour
// (feuillesTables). Les feuilles sont en portrait. En colonnes, elles
// portent le pied de la soirée ; une feuille d'un seul tour porte celui de
// son tour, et le filigrane de la règle du brouillon à ce tour
// (feuilleDuTour).
//
// Rien ne se tronque : un nom long replie dans sa colonne (§ 7.4). Chaque
// table commence une page ; une table plus longue qu'une page continue sur
// la suivante, sous son en-tête répété. La pagination se fait sur les
// hauteurs rendues, comme celle de la liste d'accueil : toutes les tables
// se rendent d'abord, hors de la vue, dans une feuille de la bande reçue,
// où se lisent la hauteur utile, et pour chaque table celle de son
// en-tête et de chacune de ses lignes (mesurerListes) ; paginerFeuilles en
// fait les pages. Une soirée sans table imprime une feuille qui le dit.
//
// La source est celle de l'aperçu, tour par tour (etatsDesTours).
//
// Sans bandeMm, la vue rend sa feuille gabarit, que l'aperçu mesure.
import { getContext } from 'svelte';
import { getContext, tick } from 'svelte';
import Feuille from './Feuille.svelte';
import { etatsDesTours, feuilleDuTour, feuillesTables, largeursColonnes } from './listes.js';
import { mesurerListes, paginerFeuilles } from './pagination.js';
let { options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
const t = getContext('t');
const seance = getContext('seance');
const charge = $derived($seance.charge);
const etats = $derived(etatsDesTours(charge, etat.proposition));
const listes = $derived(feuillesTables(charge, etats, { parTour: options.parTour }));
// Les mesures de la feuille de mesure, et ce qu'elles mesuraient ; les
// pages, null tant qu'elles ne sont pas celles des listes courantes.
const cle = $derived(JSON.stringify([options.papier, bandeMm, listes]));
let mesures = $state(null);
const pages = $derived(mesures !== null && mesures.cle === cle ? paginerFeuilles(mesures.feuilles, mesures.utile) : null);
const feuilleMesure = $derived({ ...feuille, filigrane: false });
// Mesure la feuille de mesure une fois le rendu à jour (tick) : la
// clé lue alors est celle des valeurs que le DOM porte. La lecture de
// cle relance la mesure quand elle change avant que les pages viennent.
function mesurer(conteneur) {
const voulue = cle;
let active = true;
tick().then(() => {
if (active && voulue === cle) mesures = { cle, ...mesurerListes(conteneur.querySelector('[data-contenu]')) };
});
return () => {
active = false;
};
}
// La feuille d'une page : celle de la soirée en colonnes, celle de son
// tour pour une feuille d'un seul tour.
function feuilleDeListe(liste) {
return options.parTour ? feuilleDuTour(charge, feuille, etats[liste.tours[0] - 1]) : feuille;
}
$effect(() => {
orientation = 'portrait';
});
</script>
<Feuille rang={0} papier={options.papier} orientation="portrait" {feuille} {bandeMm}>
{#snippet contenu()}
<p class="titre">{t('impression.tables.titre')}</p>
{/snippet}
</Feuille>
{#snippet blocTable(liste, rangListe, debut, fin)}
<div class="liste" data-liste-imprimee={rangListe} data-table={liste.table}>
<p class="titre">{t('feuilles.tables.titre', { table: liste.numero })}</p>
<table>
<colgroup>
{#each largeursColonnes('tables', liste.tours.length) as largeur, rang (rang)}
<col style:width="{largeur}%" />
{/each}
</colgroup>
<thead data-entete-liste>
<tr>
<th scope="col">{t('feuilles.tables.place')}</th>
{#each liste.tours as tour (tour)}
<th scope="col">{t('feuilles.tour', { tour })}</th>
{/each}
</tr>
</thead>
<tbody>
{#each liste.lignes.slice(debut, fin) as ligne, rang (ligne.place)}
<tr data-ligne-imprimee={debut + rang}>
<th scope="row">{ligne.place}</th>
{#each ligne.cellules as cellule (cellule.tour)}
<td>
{#if cellule.nom === null}
<span class="vide" data-place-vide>{t('feuilles.tables.vide')}</span>
{:else}
<span class="nom" data-nom-imprime>{cellule.nom}</span>
{/if}
{#if cellule.titre !== null}
<span class="titre-place">
<span class="libelle-titre">{cellule.titre.libelle}</span>
{#if !cellule.titre.pourvu}
<span>{t('feuilles.tables.titreNonPourvu')}</span>
{/if}
</span>
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/snippet}
{#if bandeMm === null}
<Feuille papier={options.papier} orientation="portrait" {feuille} />
{:else if pages === null}
<div class="mesure" aria-hidden="true" {@attach mesurer}>
<Feuille papier={options.papier} orientation="portrait" feuille={feuilleMesure} {bandeMm}>
{#snippet contenu()}
{#each listes as liste, rangListe (rangListe)}
{@render blocTable(liste, rangListe, 0, liste.lignes.length)}
{/each}
{/snippet}
</Feuille>
</div>
{:else if pages.length === 0}
<Feuille rang={0} papier={options.papier} orientation="portrait" {feuille} {bandeMm}>
{#snippet contenu()}
<p class="titre">{t('feuilles.tables.aucune')}</p>
{/snippet}
</Feuille>
{:else}
{#each pages as page (page.rang)}
<Feuille rang={page.rang} papier={options.papier} orientation="portrait" feuille={feuilleDeListe(listes[page.feuille])} {bandeMm}>
{#snippet contenu()}
{@render blocTable(listes[page.feuille], page.feuille, page.debut, page.fin)}
{/snippet}
</Feuille>
{/each}
{/if}
<style>
.titre {
margin: 0;
padding-bottom: var(--espace-2);
font-size: var(--taille-titre);
font-weight: bold;
}
/* Disposition fixe, sans espacement entre cellules : la hauteur d'une
table est celle de son en-tête plus la somme de ses lignes. */
table {
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-size: var(--taille-texte);
}
th,
td {
padding: var(--espace-1) var(--espace-2);
border-bottom: 1px solid var(--couleur-page-filet);
text-align: start;
vertical-align: top;
overflow-wrap: anywhere;
}
.nom,
.vide,
.titre-place {
display: block;
}
.nom {
font-weight: bold;
}
/* Une place vide se distingue par sa forme, en italique, sans couleur. */
.vide {
font-style: italic;
}
.libelle-titre {
font-style: italic;
}
/* La feuille de mesure se rend hors de la vue : elle se mesure, elle ne
se voit ni ne s'atteint. */
.mesure {
position: fixed;
top: 0;
left: 0;
z-index: -1;
visibility: hidden;
pointer-events: none;
}
</style>

View file

@ -2,33 +2,222 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La liste d'accueil imprimée (§ 11.7), dans sa forme minimale : une
// feuille en portrait, titrée, sans lignes, qui porte le pied de la
// soirée.
// La liste d'accueil imprimée (§ 11.7) : une ligne par personne non
// exclue, par ordre de nom, de prénom, d'appartenance, puis d'identifiant
// (lignesAccueil), et pour chaque tour de la soirée, en colonnes, la table,
// le siège sous sièges attribués, ou la réserve. Les feuilles sont en
// portrait et portent le pied de la soirée (« Tous les tours »).
//
// Rien ne se tronque : un nom long replie dans sa colonne (§ 7.4). La
// hauteur d'une ligne dépend donc de son texte, et la pagination se fait
// sur les hauteurs rendues : la liste entière se rend d'abord, hors de la
// vue, dans une feuille de la bande reçue, où se lisent la hauteur utile,
// celle de l'en-tête — titre et têtes de colonnes — et celle de chaque
// ligne (mesurerListes) ; paginerFeuilles en fait les pages, et chaque
// page se rend dans sa feuille, sous l'en-tête répété. La feuille de
// mesure disparaît une fois lue, et revient quand le papier, la bande ou
// les lignes changent. Les colonnes ont des largeurs fixées
// (largeursColonnes) : la mesure et les pages replient de même.
//
// Les colonnes de tour se règlent sur leur texte. La feuille de mesure
// porte une sonde : un tableau d'une colonne, sans repli, qui réunit chaque
// libellé de place et chaque tête de tour. Sa largeur, sur celle de la
// liste, donne la part d'un tour (mesurerPlace), que largeursColonnes
// borne par le plancher des noms. La mesure se fait donc en deux temps :
// la part d'abord, puis, la liste rendue aux largeurs qui en suivent, les
// hauteurs.
//
// La source est celle de l'aperçu, tour par tour : la proposition que
// porte l'état reçu, sinon le retenu, sinon les réservations
// (etatsDesTours).
//
// Sans bandeMm, la vue rend sa feuille gabarit, que l'aperçu mesure.
import { getContext } from 'svelte';
import { getContext, tick } from 'svelte';
import { cleNormalisee } from '../../application/csv.js';
import Feuille from './Feuille.svelte';
import { etatsDesTours, largeursColonnes, libellePlace, lignesAccueil } from './listes.js';
import { mesurerListes, mesurerPlace, paginerFeuilles } from './pagination.js';
let { options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
const t = getContext('t');
const seance = getContext('seance');
const charge = $derived($seance.charge);
const etats = $derived(etatsDesTours(charge, etat.proposition));
const lignes = $derived(lignesAccueil(charge, etats, cleNormalisee));
const tours = $derived(etats.map(({ tour }) => tour));
// Chaque libellé de place que la liste écrit, une fois : ce que la sonde
// porte.
const libelles = $derived([
...new Set(
lignes.flatMap(({ places }) =>
places.map((place) => {
const { cle: cleLibelle, details } = libellePlace(place);
return t(cleLibelle, details);
}),
),
),
]);
// Les mesures de la feuille de mesure, et ce qu'elles mesuraient : la
// part d'un tour, puis les hauteurs ; les pages, null tant qu'elles ne
// sont pas celles des lignes courantes.
const cle = $derived(JSON.stringify([options.papier, bandeMm, lignes]));
let part = $state(null);
let mesures = $state(null);
const largeurs = $derived(largeursColonnes('accueil', tours.length, part !== null && part.cle === cle ? part.valeur : null));
let sonde = $state(null);
const pages = $derived(mesures !== null && mesures.cle === cle ? paginerFeuilles(mesures.feuilles, mesures.utile) : null);
const feuilleMesure = $derived({ ...feuille, filigrane: false });
// Mesure la feuille de mesure une fois le rendu à jour (tick) : la
// clé lue alors est celle des valeurs que le DOM porte. Sans part à jour,
// lit la part d'un tour ; avec elle, les hauteurs de la liste rendue à ses
// largeurs. La lecture de cle et de part relance la mesure quand l'une
// change avant que les pages viennent.
function mesurer(conteneur) {
const voulue = cle;
const partLue = part;
let active = true;
tick().then(() => {
if (!active || voulue !== cle) return;
const contenu = conteneur.querySelector('[data-contenu]');
if (partLue === null || partLue.cle !== cle) {
part = { cle, valeur: mesurerPlace(sonde, contenu.querySelector('[data-liste-imprimee] table')) };
} else {
mesures = { cle, ...mesurerListes(contenu) };
}
});
return () => {
active = false;
};
}
$effect(() => {
orientation = 'portrait';
});
</script>
<Feuille rang={0} papier={options.papier} orientation="portrait" {feuille} {bandeMm}>
{#snippet contenu()}
<p class="titre">{t('impression.accueil.titre')}</p>
{/snippet}
</Feuille>
{#snippet liste(debut, fin)}
<div class="liste" data-liste-imprimee={0}>
<p class="titre">{t('feuilles.accueil.titre')}</p>
<table>
<colgroup>
{#each largeurs as largeur, rang (rang)}
<col style:width="{largeur}%" />
{/each}
</colgroup>
<thead data-entete-liste>
<tr>
<th scope="col">{t('feuilles.accueil.nom')}</th>
<th scope="col">{t('feuilles.accueil.prenom')}</th>
<th scope="col">{t('feuilles.accueil.appartenance')}</th>
{#each tours as tour (tour)}
<th scope="col">{t('feuilles.tour', { tour })}</th>
{/each}
</tr>
</thead>
<tbody>
{#each lignes.slice(debut, fin) as ligne, rang (ligne.participant)}
<tr data-ligne-imprimee={debut + rang} data-participant={ligne.participant}>
<td><span class="nom" data-nom-imprime>{ligne.nom}</span></td>
<td>
{#if ligne.prenom !== null}{ligne.prenom}{/if}
</td>
<td>
{#if ligne.appartenance !== null}{ligne.appartenance}{/if}
</td>
{#each ligne.places as place (place.tour)}
{@const libelle = libellePlace(place)}
<td>{t(libelle.cle, libelle.details)}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/snippet}
{#if bandeMm === null}
<Feuille papier={options.papier} orientation="portrait" {feuille} />
{:else if pages === null}
<div class="mesure" aria-hidden="true" {@attach mesurer}>
<Feuille papier={options.papier} orientation="portrait" feuille={feuilleMesure} {bandeMm}>
{#snippet contenu()}
{@render liste(0, lignes.length)}
<table class="sonde" bind:this={sonde}>
<tbody>
{#each tours as tour (tour)}
<tr><th scope="row">{t('feuilles.tour', { tour })}</th></tr>
{/each}
{#each libelles as libelle (libelle)}
<tr><td>{libelle}</td></tr>
{/each}
</tbody>
</table>
{/snippet}
</Feuille>
</div>
{:else}
{#each pages as page (page.rang)}
<Feuille rang={page.rang} papier={options.papier} orientation="portrait" {feuille} {bandeMm}>
{#snippet contenu()}
{@render liste(page.debut, page.fin)}
{/snippet}
</Feuille>
{/each}
{/if}
<style>
.titre {
margin: 0;
padding-bottom: var(--espace-2);
font-size: var(--taille-titre);
font-weight: bold;
}
/* Disposition fixe, sans espacement entre cellules : la hauteur de la
liste est celle de son en-tête plus la somme de ses lignes. */
table {
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-size: var(--taille-texte);
}
th,
td {
padding: var(--espace-1) var(--espace-2);
border-bottom: 1px solid var(--couleur-page-filet);
text-align: start;
vertical-align: top;
overflow-wrap: anywhere;
}
.nom {
font-weight: bold;
}
/* La sonde : à la largeur de son plus large libellé — max-content, que
le repli n'atteint pas —, hors du cours de la feuille de mesure. */
.sonde {
position: absolute;
top: 0;
left: 0;
width: max-content;
table-layout: auto;
}
/* La feuille de mesure se rend hors de la vue : elle se mesure, elle ne
se voit ni ne s'atteint. */
.mesure {
position: fixed;
top: 0;
left: 0;
z-index: -1;
visibility: hidden;
pointer-events: none;
}
</style>

View file

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

View file

@ -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<typeof feuilleDe>} feuille celle de la soirée, que l'aperçu compose
* @param {Object} etat EtatPlaces du tour
* @returns {ReturnType<typeof feuilleDe>}
*/
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)];
}

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

@ -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 <svg> du plan', ['']],
['data-poignee', 'plan : la poignée d’une table', null],
['data-proposition', 'propositions : une ligne, par son identifiant', null],