[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:
parent
e5d9026b2a
commit
62f8406d78
11 changed files with 1564 additions and 23 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
338
src/interface/impression/Listes.navigateur.test.js
Normal file
338
src/interface/impression/Listes.navigateur.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
221
src/interface/impression/listes.js
Normal file
221
src/interface/impression/listes.js
Normal 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)];
|
||||
}
|
||||
330
src/interface/impression/listes.test.js
Normal file
330
src/interface/impression/listes.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
128
src/interface/impression/pagination.js
Normal file
128
src/interface/impression/pagination.js
Normal 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;
|
||||
}
|
||||
131
src/interface/impression/pagination.test.js
Normal file
131
src/interface/impression/pagination.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
28
src/interface/libelles/feuilles.js
Normal file
28
src/interface/libelles/feuilles.js
Normal 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'],
|
||||
]);
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue