diff --git a/README.md b/README.md
index ab412e5..44926b4 100644
--- a/README.md
+++ b/README.md
@@ -121,7 +121,7 @@ l'exécution elle-même —, son statut et la façon de le relever. Une valeur a
| --- | --- | --- | --- | --- | --- | --- | --- |
| `SEUIL_GLISSER_PX` | déplacement du pointeur, entre pointerdown et pointerup, au-delà duquel un appui devient un glissement | 4 px | défaut de travail | manuel | vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre pointerdown et pointerup, × 1,5, écrit dans ce registre | — | pavé tactile de la machine de référence |
| `DUREE_PRESSION_LONGUE_MS` | durée d'un appui immobile au-delà de laquelle il ouvre le menu d'une personne | 500 ms | défaut de travail | manuel | à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, sur dix essais, écrite dans ce registre | — | écran tactile de la machine de référence |
-| `SEUIL_SIEGES_LISIBLES` | plus grand nombre de places d'une table dont la liste de noms reste lisible | 16 places | défaut de travail | banc | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre | 18 places | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
+| `SEUIL_SIEGES_LISIBLES` | plus grand nombre de places d'une table dont la liste de noms reste lisible | 18 places | mesurée | banc | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule, ronde puis carrée, par dichotomie ; le plus petit des deux seuils, écrit dans ce registre | 18 places | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `K_LISTES` | échelle sous laquelle le plan retire les listes de noms | 0,3 px/cm | défaut de travail | manuel | une personne qui n'a pas écrit le logiciel lit les listes de la grande démonstration sur la machine de référence à k décroissant ; le plus petit k où elle les lit sans erreur, écrit dans ce registre | — | grande démonstration, 33 tables avec listes, machine de référence, cadre de 1 366 × 700 px |
| `LARGEUR_BLOC_NOMS_CM` | largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège | 500 cm | défaut de travail | manuel | relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre | — | grande démonstration au zoom par défaut |
| `HAUTEUR_LIGNE` | hauteur d'une ligne d'une liste de noms, dans le dessin | relevée à l’exécution | mesurée | exécution | mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des listes ; aucune valeur n'est écrite dans ce registre | — | police calculée des listes, relevée à chaque montage du plan |
@@ -135,7 +135,7 @@ l'exécution elle-même —, son statut et la façon de le relever. Une valeur a
| `CORPS_MIN_IMPRESSION_MM` | hauteur de caractère du plan imprimé en deçà de laquelle le plan se découpe en pages | 2,5 mm | défaut de travail | manuel | page d'essai à corps décroissants imprimée sur l'imprimante visée, lue à bout de bras ; le plus petit corps lu sans erreur, écrit dans ce registre | — | imprimante visée, chaque papier des sorties, lecture à bout de bras |
| `MARGE_PAGE_MM` | marge de chaque bord d'une page imprimée par l'aperçu | 10 mm | défaut de travail | manuel | marge non imprimable mesurée sur chaque imprimante visée (une page de filets à 1 mm d'écart, lue à la règle) ; la plus grande, écrite dans ce registre | — | imprimantes visées, chaque papier des sorties |
| `HAUTEUR_BANDE_IMPRESSION` | hauteur de ce que porte une feuille imprimée hors de sa zone de contenu : pied, mentions, barre, cartouche | relevée à l’exécution | mesurée | exécution | mesurée à l'ouverture de l'aperçu : tout ce qu'une feuille porte hors de [data-contenu] ; aucune valeur n'est écrite dans ce registre | — | rendu de l'aperçu, relevé à chaque ouverture |
-| `TOLERANCE_CHAINE_TEMOIN` | écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police | 0,01 rapport | défaut de travail | banc | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures ; écrit dans ce registre | 0,011 rapport | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
+| `TOLERANCE_CHAINE_TEMOIN` | écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police | 0,011 rapport | mesurée | banc | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures, chaîne témoin à 16 px, crénage et ligatures coupés ; arrondi par excès à deux chiffres significatifs, écrit dans ce registre | 0,011 rapport | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `RALENTISSEMENT_PLEINE_CHARGE` | ralentissement d'une génération quand tous les processeurs logiques travaillent, t(L) / t(1) | 2 × | défaut de travail | banc | banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le rapport ρ des médianes, écrit dans ce registre | 2,79 × | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
diff --git a/scripts/banc/lancer.test.js b/scripts/banc/lancer.test.js
index 47cd4eb..bff2de3 100644
--- a/scripts/banc/lancer.test.js
+++ b/scripts/banc/lancer.test.js
@@ -13,7 +13,7 @@ import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CONSTANTES } from '../../src/geometrie/constantes.js';
import { projetBanc } from './projet.js';
-import { verifierReleve } from './releve.js';
+import { accordRegistre, verifierReleve } from './releve.js';
import releveCommite from './releve.json' with { type: 'json' };
import {
ATTENDUES,
@@ -312,13 +312,22 @@ describe('lancer : executer, sur des effets factices', () => {
assert.deepEqual(noms(releve), [...ATTENDUES]);
const texte = p.lignes.join('\n');
assert.ok(texte.includes(regionConstantes({ releve })));
- assert.match(texte, /SEUIL_SIEGES_LISIBLES : registre 16 places, relevé 17 places — à adopter/);
+ // Les valeurs du registre se lisent au registre : une adoption ne change
+ // pas ce que l'épreuve éprouve.
+ const registre = (nom) => CONSTANTES.find((c) => c.nom === nom).valeur;
+ assert.ok(texte.includes(`SEUIL_SIEGES_LISIBLES : registre ${registre('SEUIL_SIEGES_LISIBLES')} places, relevé 17 places — à adopter`));
assert.match(
texte,
/RALENTISSEMENT_PLEINE_CHARGE : registre 2 ×, relevé 2,5 × — relevé, non adopté : défaut de travail sous la machine de référence/,
);
assert.doesNotMatch(texte, /RALENTISSEMENT_PLEINE_CHARGE : .*à adopter/);
- assert.match(texte, /Accord du registre avec le relevé : aucun écart/);
+ // L'accord imprimé est celui du registre avec le relevé écrit : une
+ // constante adoptée que ce relevé inventé contredit y est nommée.
+ const ecarts = accordRegistre(CONSTANTES, releve);
+ const accord = ecarts.length === 0
+ ? 'Accord du registre avec le relevé : aucun écart.'
+ : ['Accord du registre avec le relevé :', ...ecarts.map(({ nom, faute }) => `- ${nom} : ${faute}`)].join('\n');
+ assert.ok(texte.includes(accord), accord);
});
test('une épreuve du banc tombée (un contrôle du harnais) : code 1, aucun relevé écrit', async () => {
diff --git a/src/geometrie/constantes.js b/src/geometrie/constantes.js
index 3761b0d..8b5f9cf 100644
--- a/src/geometrie/constantes.js
+++ b/src/geometrie/constantes.js
@@ -57,16 +57,19 @@ export const CONSTANTES = Object.freeze(
},
{
nom: 'SEUIL_SIEGES_LISIBLES',
- valeur: 16,
+ valeur: 18,
unite: 'places',
mesure: "plus grand nombre de places d'une table dont la liste de noms reste lisible",
raison: "au-delà, le dessin d'une table cesse d'être lisible, et l'application le signale (§ 6.2)",
- configuration: 'banc de mesure, cadre de 1 366 × 700 px',
+ configuration:
+ 'banc de mesure, cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, ' +
+ '16 processeurs logiques, processeur non ralenti, police Noto Sans',
proprietaire: 'banc',
- statut: 'defautDeTravail',
+ statut: 'mesuree',
protocole:
'banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur ' +
- "de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre",
+ "de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule, ronde puis carrée, " +
+ 'par dichotomie ; le plus petit des deux seuils, écrit dans ce registre',
},
{
nom: 'K_LISTES',
@@ -251,18 +254,21 @@ export const CONSTANTES = Object.freeze(
},
{
nom: 'TOLERANCE_CHAINE_TEMOIN',
- valeur: 0.01,
+ valeur: 0.011,
unite: 'rapport',
mesure: 'écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police',
raison:
"la mesure du § 19.4 trahit une police de repli ; trop serrée, elle échoue sur la gigue du rendu, trop " +
'large, elle laisse passer le repli',
- configuration: 'banc de mesure, Chromium du projet, cadre de 1 366 × 700 px, facteur 1',
+ configuration:
+ 'banc de mesure, cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, ' +
+ '16 processeurs logiques, processeur non ralenti, police Noto Sans',
proprietaire: 'banc',
- statut: 'defautDeTravail',
+ statut: 'mesuree',
protocole:
"banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli " +
- 'forcée, et au moins trois fois la gigue de dix mesures ; écrit dans ce registre',
+ 'forcée, et au moins trois fois la gigue de dix mesures, chaîne témoin à 16 px, crénage et ligatures ' +
+ 'coupés ; arrondi par excès à deux chiffres significatifs, écrit dans ce registre',
},
{
nom: 'RALENTISSEMENT_PLEINE_CHARGE',
diff --git a/src/geometrie/constantes.test.js b/src/geometrie/constantes.test.js
index da244cc..24a94b9 100644
--- a/src/geometrie/constantes.test.js
+++ b/src/geometrie/constantes.test.js
@@ -7,6 +7,10 @@
// l'accord du registre avec le relevé committé du banc : le banc écrit un
// relevé, jamais le registre, et une constante du banc déclarée mesurée
// porte la valeur que le relevé propose, sur la configuration qu'il décrit.
+// Deux valeurs du relevé sont adoptées — le seuil de sièges lisibles et la
+// tolérance de la chaîne témoin — ; le ralentissement à pleine charge garde
+// son défaut de travail : la machine de référence est le poste de
+// développement, ρ à son défaut (Q13 b).
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { accordRegistre } from '../../scripts/banc/releve.js';
@@ -18,7 +22,7 @@ import { CONSTANTES, constante, verifierRegistre } from './constantes.js';
const CONTRAT = [
['SEUIL_GLISSER_PX', 4, 'px', 'manuel', 'defautDeTravail'],
['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel', 'defautDeTravail'],
- ['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc', 'defautDeTravail'],
+ ['SEUIL_SIEGES_LISIBLES', 18, 'places', 'banc', 'mesuree'],
['K_LISTES', 0.3, 'px/cm', 'manuel', 'defautDeTravail'],
['LARGEUR_BLOC_NOMS_CM', 500, 'cm', 'manuel', 'defautDeTravail'],
['HAUTEUR_LIGNE', null, 'cm', 'execution', 'mesuree'],
@@ -32,10 +36,13 @@ const CONTRAT = [
['CORPS_MIN_IMPRESSION_MM', 2.5, 'mm', 'manuel', 'defautDeTravail'],
['MARGE_PAGE_MM', 10, 'mm', 'manuel', 'defautDeTravail'],
['HAUTEUR_BANDE_IMPRESSION', null, 'mm', 'execution', 'mesuree'],
- ['TOLERANCE_CHAINE_TEMOIN', 0.01, 'rapport', 'banc', 'defautDeTravail'],
+ ['TOLERANCE_CHAINE_TEMOIN', 0.011, 'rapport', 'banc', 'mesuree'],
['RALENTISSEMENT_PLEINE_CHARGE', 2, '×', 'banc', 'defautDeTravail'],
];
+// Les constantes du banc dont le registre a adopté la valeur relevée.
+const ADOPTEES = ['SEUIL_SIEGES_LISIBLES', 'TOLERANCE_CHAINE_TEMOIN'];
+
// Une copie du registre, entrée par entrée, que l'épreuve peut altérer.
const copie = () => CONSTANTES.map((entree) => ({ ...entree }));
@@ -52,9 +59,10 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
}
});
- test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail ; une constante relevée à l\'exécution est mesurée', () => {
+ test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail ; une constante relevée à l\'exécution, ou adoptée du relevé, est mesurée', () => {
for (const { nom, proprietaire, statut } of CONSTANTES) {
- assert.equal(statut, proprietaire === 'execution' ? 'mesuree' : 'defautDeTravail', nom);
+ const mesuree = proprietaire === 'execution' || ADOPTEES.includes(nom);
+ assert.equal(statut, mesuree ? 'mesuree' : 'defautDeTravail', nom);
}
});
@@ -137,6 +145,32 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
describe('constantes : accord avec le relevé du banc (§ 19.10, § 14.1)', () => {
test('chaque constante du banc déclarée mesurée porte la valeur du relevé committé et cite sa configuration', () => {
assert.ok(CONSTANTES.some((c) => c.proprietaire === 'banc'), 'le registre ne porte aucune constante du banc');
+ // L'accord n'examine que les constantes mesurées : sans elles, il serait
+ // vert sans rien éprouver (§ 14.2).
+ assert.deepEqual(
+ CONSTANTES.filter((c) => c.proprietaire === 'banc' && c.statut === 'mesuree').map(({ nom }) => nom),
+ ADOPTEES,
+ );
assert.deepEqual(accordRegistre(CONSTANTES, releveCommite), []);
});
+
+ test('une adoption porte la valeur proposée par le relevé, et sa configuration cite le cadre, le navigateur et la police du relevé', () => {
+ for (const nom of ADOPTEES) {
+ const entree = CONSTANTES.find((c) => c.nom === nom);
+ const mesure = releveCommite.mesures.find((m) => m.nom === nom);
+ assert.equal(entree.valeur, mesure.valeurProposee, nom);
+ assert.ok(entree.configuration.includes(releveCommite.configuration.navigateur), nom);
+ assert.ok(entree.configuration.includes(`police ${mesure.police}`), nom);
+ assert.match(entree.configuration, /cadre de 1 366 × 700 px, facteur 1/, nom);
+ }
+ // Une valeur du registre qui s'écarte du relevé est une faute de l'accord.
+ const ecartee = CONSTANTES.map((c) => (c.nom === 'SEUIL_SIEGES_LISIBLES' ? { ...c, valeur: 16 } : c));
+ assert.deepEqual(accordRegistre(ecartee, releveCommite), [{ nom: 'SEUIL_SIEGES_LISIBLES', faute: 'valeur' }]);
+ });
+
+ test('le ralentissement à pleine charge garde sa valeur de départ, défaut de travail, quoi que le relevé propose (Q13 b)', () => {
+ const entree = CONSTANTES.find((c) => c.nom === 'RALENTISSEMENT_PLEINE_CHARGE');
+ assert.deepEqual([entree.valeur, entree.statut], [2, 'defautDeTravail']);
+ assert.notEqual(releveCommite.mesures.find((m) => m.nom === 'RALENTISSEMENT_PLEINE_CHARGE').valeurProposee, entree.valeur);
+ });
});
diff --git a/src/interface/impression/FeuillesTables.svelte b/src/interface/impression/FeuillesTables.svelte
index 826f4d4..5b59392 100644
--- a/src/interface/impression/FeuillesTables.svelte
+++ b/src/interface/impression/FeuillesTables.svelte
@@ -32,17 +32,29 @@
// (mesurerListes). Une ligne encore plus haute qu'une page — un nom fait
// de nombreux mots courts — resserre les groupes de sa feuille
// (resserrerGroupes), et la passe se refait ; sinon paginerFeuilles en
- // fait les pages. Une ligne qui passe la page sous un seul tour par
- // groupe, sa colonne à toute la largeur que la place laisse, reste seule
- // sur sa page.
+ // fait les pages. Une ligne qui passe encore la page sous un seul tour
+ // par groupe, sa colonne à toute la largeur que la place laisse — un nom
+ // de milliers de caractères —, ne se coupe pas au bord de la page : la
+ // même passe en essaie les morceaux sur la ligne rendue (couperLigne), et
+ // la ligne se continue, un morceau par page, sous l'en-tête répété et le
+ // numéro de sa place (morcelerPages, trancher).
//
// 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, tick } from 'svelte';
import Feuille from './Feuille.svelte';
- import { etatsDesTours, feuilleDuTour, feuillesTables, grouperTours, largeursColonnes, resserrerGroupes, toursParGroupe } from './listes.js';
- import { mesurerListes, mesurerPlace, paginerFeuilles } from './pagination.js';
+ import {
+ etatsDesTours,
+ feuilleDuTour,
+ feuillesTables,
+ grouperTours,
+ largeursColonnes,
+ resserrerGroupes,
+ textesCellule,
+ toursParGroupe,
+ } from './listes.js';
+ import { couperLigne, mesurerListes, mesurerPlace, morcelerPages, paginerFeuilles, trancher } from './pagination.js';
let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
@@ -63,7 +75,9 @@
const groupes = $derived(parGroupe === null ? null : grouperTours(listes, parGroupe));
const cleGroupes = $derived(JSON.stringify([cle, parGroupe]));
let mesures = $state(null);
- const pages = $derived(mesures !== null && mesures.cle === cleGroupes ? paginerFeuilles(mesures.feuilles, mesures.utile) : null);
+ const pages = $derived(
+ mesures !== null && mesures.cle === cleGroupes ? morcelerPages(paginerFeuilles(mesures.feuilles, mesures.utile), mesures.coupures) : null,
+ );
const feuilleMesure = $derived({ ...feuille, filigrane: false });
// Les sondes de la première passe, une par feuille.
let sondes = $state([]);
@@ -71,8 +85,9 @@
// Mesure la feuille de mesure une fois le rendu à jour (tick) : la clé
// lue alors est celle des valeurs que le DOM porte. Sans tailles à jour,
// lit les sondes ; avec elles, les hauteurs des groupes, qui donnent les
- // pages ou des tailles resserrées. La lecture des clés relance la mesure
- // quand l'une change avant que les pages viennent.
+ // pages ou des tailles resserrées, et les morceaux de chaque ligne encore
+ // seule. La lecture des clés relance la mesure quand l'une change avant
+ // que les pages viennent.
function mesurer(conteneur) {
const voulue = cleGroupes;
const valeurs = parGroupe;
@@ -87,9 +102,20 @@
return;
}
const lues = mesurerListes(contenu);
- const resserrees = resserrerGroupes(valeurs, groupesLus, paginerFeuilles(lues.feuilles, lues.utile));
- if (resserrees === null) mesures = { cle: voulue, ...lues };
- else tailles = { cle, valeurs: resserrees };
+ const paginees = paginerFeuilles(lues.feuilles, lues.utile);
+ const resserrees = resserrerGroupes(valeurs, groupesLus, paginees);
+ if (resserrees !== null) {
+ tailles = { cle, valeurs: resserrees };
+ return;
+ }
+ const listesRendues = contenu.querySelectorAll('[data-liste-imprimee]');
+ const coupures = [];
+ for (const { feuille: rang, debut, seule } of paginees) {
+ if (!seule) continue;
+ const ligne = listesRendues[rang].querySelector('tbody').rows[debut];
+ coupures.push({ feuille: rang, ligne: debut, bornes: couperLigne(ligne, lues.utile - lues.feuilles[rang].entete) });
+ }
+ mesures = { cle: voulue, ...lues, coupures };
});
return () => {
active = false;
@@ -107,18 +133,25 @@
});
-{#snippet celluleTour(cellule)}
+
+{#snippet celluleTour(cellule, parts = null)}
+ {@const [nom, titre, nonPourvu] = parts ?? textesCellule(cellule, t)}
- {#if cellule.nom === null}
- {t('feuilles.tables.vide')}
- {:else}
- {cellule.nom}
+ {#if nom !== ''}
+ {#if cellule.nom === null}
+ {nom}
+ {:else}
+ {nom}
+ {/if}
{/if}
- {#if cellule.titre !== null}
+ {#if titre !== '' || nonPourvu !== ''}
- {cellule.titre.libelle}
- {#if !cellule.titre.pourvu}
- {t('feuilles.tables.titreNonPourvu')}
+ {#if titre !== ''}
+ {titre}
+ {/if}
+ {#if nonPourvu !== ''}
+ {nonPourvu}
{/if}
{/if}
@@ -142,7 +175,7 @@
{/snippet}
-{#snippet blocTable(liste, rangListe, debut, fin)}
+{#snippet blocTable(liste, rangListe, debut, fin, morceau = null)}
{t('feuilles.tables.titre', { table: liste.numero })}
@@ -161,10 +194,14 @@
{#each liste.lignes.slice(debut, fin) as ligne, rang (ligne.place)}
+
+ {@const parts = morceau === null ? null : trancher(ligne.cellules.flatMap((cellule) => textesCellule(cellule, t)), morceau.debut, morceau.fin)}
| {ligne.place} |
- {#each ligne.cellules as cellule (cellule.tour)}
- {@render celluleTour(cellule)}
+ {#each ligne.cellules as cellule, rangCellule (cellule.tour)}
+ {@render celluleTour(cellule, parts === null ? null : parts.slice(3 * rangCellule, 3 * rangCellule + 3))}
{/each}
{/each}
@@ -201,7 +238,7 @@
{#each pages as page (page.rang)}
{#snippet contenu()}
- {@render blocTable(groupes[page.feuille], page.feuille, page.debut, page.fin)}
+ {@render blocTable(groupes[page.feuille], page.feuille, page.debut, page.fin, page.morceau)}
{/snippet}
{/each}
diff --git a/src/interface/impression/ListeAccueil.svelte b/src/interface/impression/ListeAccueil.svelte
index 52403c3..18f6443 100644
--- a/src/interface/impression/ListeAccueil.svelte
+++ b/src/interface/impression/ListeAccueil.svelte
@@ -19,6 +19,14 @@
// les lignes changent. Les colonnes ont des largeurs fixées
// (largeursColonnes) : la mesure et les pages replient de même.
//
+ // Une ligne plus haute qu'une page — un nom de milliers de caractères —
+ // ne se coupe pas au bord de la page : la même passe en essaie les
+ // morceaux sur la ligne rendue (couperLigne), et la ligne se continue, un
+ // morceau par page, sous l'en-tête répété. Ses textes se suivent dans
+ // l'ordre de ses cellules — nom, prénom, appartenance, places
+ // (textesLigneAccueil) — et chaque page pose de chaque cellule la part qui
+ // tombe dans son morceau (morcelerPages, trancher).
+ //
// 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
@@ -35,8 +43,8 @@
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';
+ import { etatsDesTours, largeursColonnes, libellePlace, lignesAccueil, textesLigneAccueil } from './listes.js';
+ import { couperLigne, mesurerListes, mesurerPlace, morcelerPages, paginerFeuilles, trancher } from './pagination.js';
let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
@@ -68,14 +76,17 @@
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 pages = $derived(
+ mesures !== null && mesures.cle === cle ? morcelerPages(paginerFeuilles(mesures.feuilles, mesures.utile), mesures.coupures) : 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.
+ // largeurs, et les morceaux de chaque ligne seule sur sa page. 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;
@@ -86,7 +97,12 @@
if (partLue === null || partLue.cle !== cle) {
part = { cle, valeur: mesurerPlace(sonde, contenu.querySelector('[data-liste-imprimee] table')) };
} else {
- mesures = { cle, ...mesurerListes(contenu) };
+ const lues = mesurerListes(contenu);
+ const corps = contenu.querySelector('[data-liste-imprimee] tbody');
+ const coupures = paginerFeuilles(lues.feuilles, lues.utile)
+ .filter(({ seule }) => seule)
+ .map(({ feuille, debut }) => ({ feuille, ligne: debut, bornes: couperLigne(corps.rows[debut], lues.utile - lues.feuilles[feuille].entete) }));
+ mesures = { cle, ...lues, coupures };
}
});
return () => {
@@ -99,7 +115,10 @@
});
-{#snippet liste(debut, fin)}
+
+{#snippet liste(debut, fin, morceau = null)}
{t('feuilles.accueil.titre')}
@@ -120,17 +139,16 @@
{#each lignes.slice(debut, fin) as ligne, rang (ligne.participant)}
+ {@const textes = textesLigneAccueil(ligne, t)}
+ {@const [nom, prenom, appartenance, ...places] = morceau === null ? textes : trancher(textes, morceau.debut, morceau.fin)}
- | {ligne.nom} |
- {#if ligne.prenom !== null}{ligne.prenom}{/if}
+ {#if nom !== ''}{nom}{/if}
|
-
- {#if ligne.appartenance !== null}{ligne.appartenance}{/if}
- |
- {#each ligne.places as place (place.tour)}
- {@const libelle = libellePlace(place)}
- {t(libelle.cle, libelle.details)} |
+ {prenom} |
+ {appartenance} |
+ {#each places as place, rangPlace (rangPlace)}
+ {place} |
{/each}
{/each}
@@ -163,7 +181,7 @@
{#each pages as page (page.rang)}
{#snippet contenu()}
- {@render liste(page.debut, page.fin)}
+ {@render liste(page.debut, page.fin, page.morceau)}
{/snippet}
{/each}
diff --git a/src/interface/impression/Listes.navigateur.test.js b/src/interface/impression/Listes.navigateur.test.js
index 1ea7b3d..11e9de4 100644
--- a/src/interface/impression/Listes.navigateur.test.js
+++ b/src/interface/impression/Listes.navigateur.test.js
@@ -16,7 +16,11 @@
// largeur de son plus long mot ; une soirée dont les tours n'y tiennent
// pas, ou dont une ligne passerait la hauteur d'une page, se scinde en
// groupes de tours, chaque groupe sous ses têtes de tour, chaque tour une
-// fois par table, et aucune ligne ne déborde de sa page.
+// fois par table, et aucune ligne ne déborde de sa page. Une ligne plus
+// haute qu'une page, sur la feuille par table comme sur la liste d'accueil,
+// se continue sur les feuilles suivantes : rien d'elle hors des zones de
+// contenu, et ses parts mises bout à bout redonnent la ligne entière, chaque
+// texte une fois.
//
// 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
@@ -27,6 +31,7 @@
// (§ 14.8).
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { appliquerCommande } from '../../application/commandes.js';
+import { cleNormalisee } from '../../application/csv.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { nomAffiche } from '../../application/personnes.js';
import { estBrouillon } from '../../application/sorties.js';
@@ -36,6 +41,7 @@ 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 { etatsDesTours, feuillesTables, lignesAccueil, textesCellule, textesLigneAccueil } from './listes.js';
import { paginer } from './pagination.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@@ -410,3 +416,122 @@ describe('la feuille par table : des tours nombreux sous un nom long (§ 7.4)',
for (const [table, suite] of groupesParTable(m)) expect(suite, `table ${table}`).toEqual([attendues]);
});
});
+
+// Les lignes plus hautes qu'une page (§ 7.4) : un nom de milliers de
+// caractères en mots courts, que même une colonne à toute la largeur que la
+// place laisse ne tient pas sur une page.
+const NOM_DE_PAGES = 'Ombrelle Pervenche '.repeat(1500).trim();
+
+// La principale, la personne 1 renommée du nom de pages, à tours tours.
+function chargeNomDePages(tours) {
+ return appliquerCommande(
+ 'changerTours',
+ appliquerCommande('modifierParticipant', chargePrincipale(), { id: 1, champs: { nom: NOM_DE_PAGES } }).charge,
+ { tours },
+ ).charge;
+}
+
+// Vérifie que rien d'une ligne ne tombe hors de la zone de contenu de sa
+// feuille — ni sa boîte, ni aucun fragment de son texte —, et que chaque
+// feuille porte un en-tête de liste.
+function verifierDansLesZones(m) {
+ for (const feuille of feuilles(m)) {
+ const zone = boite(feuille.querySelector('[data-contenu]'));
+ expect(feuille.querySelectorAll('[data-entete-liste]'), 'un en-tête par feuille').toHaveLength(1);
+ for (const ligne of tous(feuille, '[data-ligne-imprimee]')) {
+ const plage = document.createRange();
+ plage.selectNodeContents(ligne);
+ for (const rect of [boite(ligne), ...plage.getClientRects()]) {
+ const ou = `feuille ${feuille.dataset.feuille}, ligne ${ligne.dataset.ligneImprimee}`;
+ expect(rect.top, ou).toBeGreaterThanOrEqual(zone.top - JEU_PX);
+ expect(rect.bottom, ou).toBeLessThanOrEqual(zone.bottom + JEU_PX);
+ }
+ }
+ }
+}
+
+// Ce que les cellules de données d'une ligne rendue posent : ses nœuds de
+// texte non blancs, dans l'ordre du document, mis bout à bout.
+function texteDesDonnees(ligne) {
+ return [...ligne.cells]
+ .filter((cellule) => cellule.tagName === 'TD')
+ .flatMap((cellule) => {
+ const lus = [];
+ const parcours = document.createTreeWalker(cellule, NodeFilter.SHOW_TEXT);
+ for (let noeud = parcours.nextNode(); noeud !== null; noeud = parcours.nextNode()) if (noeud.data.trim() !== '') lus.push(noeud.data);
+ return lus;
+ })
+ .join('');
+}
+
+describe('la feuille par table : une ligne plus haute qu’une page sous un seul tour (§ 7.4)', () => {
+ test('deux tours, A4 : continuée sous l’en-tête répété, dans les zones ; le nom, puis le titre une fois', async () => {
+ const charge = chargeNomDePages(2);
+ const m = await ouvrirApercu(contexte, charge);
+ await choisirVue(m, 'tables');
+ const attendu = plat(nomAffiche(charge.participants.find(({ id }) => id === 1)));
+ verifierDansLesZones(m);
+
+ // La ligne du nom, à chaque tour : continuée sur plusieurs feuilles, ses
+ // morceaux mis bout à bout redonnent le nom entier, sans marque
+ // d'abréviation.
+ const morceaux = tous(m.racine, '[data-impression] [data-nom-imprime]').filter((noeud) => noeud.textContent.includes('Ombrelle Pervenche Ombrelle'));
+ const parListe = new Map();
+ for (const morceau of morceaux) {
+ const liste = morceau.closest('[data-liste-imprimee]').dataset.listeImprimee;
+ if (!parListe.has(liste)) parListe.set(liste, []);
+ parListe.get(liste).push(morceau);
+ }
+ expect(parListe.size, 'la ligne du nom à chacun des deux tours').toBe(2);
+ // Ce que la cellule de la ligne continuée pose entière : le nom, le
+ // titre de la place, « non pourvu » s'il y a lieu (textesCellule), lu
+ // sur la source de l'aperçu.
+ const donnees = feuillesTables(charge, etatsDesTours(charge, m.ui.etat().proposition));
+ const tours = Array.from({ length: charge.evenement.tours }, (_, rang) => rang + 1);
+ for (const [liste, suite] of parListe) {
+ const pages = new Set(suite.map((morceau) => morceau.closest('[data-feuille]').dataset.feuille));
+ expect(pages.size, `liste ${liste} : la ligne continuée sur plusieurs feuilles`).toBeGreaterThan(1);
+ expect(plat(suite.map((morceau) => morceau.textContent).join('')), `liste ${liste} : le nom entier`).toBe(attendu);
+
+ // La même ligne sur chacune de ses feuilles, dans leur ordre : ses
+ // parts mises bout à bout redonnent la cellule entière — le titre une
+ // seule fois, après le dernier morceau du nom.
+ const bloc = suite[0].closest('[data-liste-imprimee]');
+ const rang = Number(suite[0].closest('[data-ligne-imprimee]').dataset.ligneImprimee);
+ const entete = [...bloc.querySelector('[data-entete-liste] tr').cells].slice(1).map(texte);
+ expect(entete, `liste ${liste} : un seul tour`).toHaveLength(1);
+ const tour = tours.find((candidat) => t('feuilles.tour', { tour: candidat }) === entete[0]);
+ const cellule = donnees.find(({ table }) => table === Number(bloc.dataset.table)).lignes[rang].cellules.find((c) => c.tour === tour);
+ expect(cellule.occupant, `liste ${liste}`).toBe(1);
+ expect(cellule.titre, `liste ${liste} : la place porte un titre`).not.toBeNull();
+ const lignes = tous(m.racine, `[data-impression] [data-liste-imprimee="${liste}"] [data-ligne-imprimee="${rang}"]`);
+ expect(lignes.length, `liste ${liste}`).toBe(pages.size);
+ expect(lignes.map(texteDesDonnees).join(''), `liste ${liste} : la cellule entière`).toBe(textesCellule(cellule, t).join(''));
+ }
+ expect(dans(m, '[data-impression]').textContent).not.toContain(ABREVIATION);
+ });
+});
+
+describe('la liste d’accueil : une ligne plus haute qu’une page (§ 7.4)', () => {
+ test('deux tours, A4 : continuée sous l’en-tête répété, dans les zones ; chaque texte une fois, dans l’ordre', async () => {
+ const charge = chargeNomDePages(2);
+ const m = await ouvrirApercu(contexte, charge, { section: 'participants' });
+ await choisirVue(m, 'accueil');
+ verifierDansLesZones(m);
+
+ const lignes = tous(m.racine, '[data-impression] [data-participant="1"]');
+ const pages = new Set(lignes.map((ligne) => ligne.closest('[data-feuille]').dataset.feuille));
+ expect(pages.size, 'la ligne continuée sur plusieurs feuilles').toBeGreaterThan(1);
+ expect(lignes.length).toBe(pages.size);
+ expect(
+ lignes.map((ligne) => ligne.querySelector('[data-nom-imprime]')?.textContent ?? '').join(''),
+ 'le nom entier',
+ ).toBe(NOM_DE_PAGES);
+ const ligne = lignesAccueil(charge, etatsDesTours(charge, m.ui.etat().proposition), cleNormalisee).find(({ participant }) => participant === 1);
+ expect(lignes.map(texteDesDonnees).join(''), 'la ligne entière').toBe(textesLigneAccueil(ligne, t).join(''));
+ // Chaque autre personne garde sa ligne entière, sur une feuille.
+ const autres = tous(m.racine, '[data-impression] [data-participant]').filter((autre) => autre.dataset.participant !== '1');
+ expect(autres).toHaveLength(charge.participants.filter(({ id, exclu }) => id !== 1 && !exclu).length);
+ expect(dans(m, '[data-impression]').textContent).not.toContain(ABREVIATION);
+ });
+});
diff --git a/src/interface/impression/listes.js b/src/interface/impression/listes.js
index 31abd1b..64ff93c 100644
--- a/src/interface/impression/listes.js
+++ b/src/interface/impression/listes.js
@@ -142,6 +142,55 @@ export function feuillesTables(charge, etatsParTour, { parTour = false } = {}) {
});
}
+/**
+ * Les textes qu'une cellule de la feuille par table pose, dans l'ordre du
+ * document : le nom de l'occupant, ou la place vide ; le libellé du titre
+ * de la place ; « non pourvu » quand rien ne le pourvoit. Le texte vide
+ * pour ce que la cellule ne porte pas. Une ligne plus haute qu'une page se
+ * continue sur les suivantes par morceaux de cette suite (couperTexte,
+ * trancher).
+ *
+ * @param {{nom: string|null, titre: {libelle: string, pourvu: boolean}|null}} cellule
+ * @param {(cle: string) => string} t
+ * @returns {[string, string, string]}
+ */
+export function textesCellule({ nom, titre }, t) {
+ return [
+ nom ?? t('feuilles.tables.vide'),
+ titre?.libelle ?? '',
+ titre !== null && !titre.pourvu ? t('feuilles.tables.titreNonPourvu') : '',
+ ];
+}
+
+// Un champ de la liste d'accueil tel qu'une ligne le pose : le texte vide
+// pour un champ absent ou fait de blancs, qui ne pose aucun nœud de texte
+// que couperLigne lise.
+const champPose = (texte) => (texte === null || texte.trim() === '' ? '' : texte);
+
+/**
+ * Les textes qu'une ligne de la liste d'accueil pose, dans l'ordre du
+ * document : le nom, le prénom, l'appartenance, puis le libellé de la place
+ * à chaque tour (libellePlace). Le texte vide pour un champ absent ou fait
+ * de blancs. Une ligne plus haute qu'une page se continue sur les suivantes
+ * par morceaux de cette suite (couperLigne, trancher).
+ *
+ * @param {{nom: string, prenom: string|null, appartenance: string|null,
+ * places: Array<{table: number|null, siege: number|null}>}} ligne lignesAccueil
+ * @param {(cle: string, details?: Object) => string} t
+ * @returns {string[]}
+ */
+export function textesLigneAccueil({ nom, prenom, appartenance, places }, t) {
+ return [
+ champPose(nom),
+ champPose(prenom),
+ champPose(appartenance),
+ ...places.map((place) => {
+ const { cle, details } = libellePlace(place);
+ return t(cle, details);
+ }),
+ ];
+}
+
/**
* 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,
diff --git a/src/interface/impression/listes.test.js b/src/interface/impression/listes.test.js
index 3a72687..d6f4381 100644
--- a/src/interface/impression/listes.test.js
+++ b/src/interface/impression/listes.test.js
@@ -7,7 +7,9 @@
// 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 groupes de tours de la feuille par table, leur taille tirée de la
-// part mesurée d'un tour, et leur resserrement sous une page seule.
+// part mesurée d'un tour, et leur resserrement sous une page seule ; les
+// textes d'une cellule de la feuille par table et d'une ligne de la liste
+// d'accueil, dans l'ordre où elles les posent.
// 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';
@@ -26,6 +28,8 @@ import {
libellePlace,
lignesAccueil,
resserrerGroupes,
+ textesCellule,
+ textesLigneAccueil,
toursParGroupe,
} from './listes.js';
@@ -404,3 +408,43 @@ describe('les groupes de tours de la feuille par table', () => {
assert.deepEqual(resserrerGroupes([1, 5], unParGroupe, [...seuleDUnTour, { feuille: unParGroupe.length - 1, seule: true }]), [1, 3]);
});
});
+
+describe('textesCellule : les textes d’une cellule de la feuille par table, dans l’ordre où elle les pose', () => {
+ test('le nom ou la place vide, puis le libellé du titre, puis « non pourvu » ; le texte vide pour ce qui manque', () => {
+ assert.deepEqual(textesCellule({ tour: 1, occupant: 1, nom: 'Iris Ombrelle', titre: { libelle: 'animateur', pourvu: true } }, t), [
+ 'Iris Ombrelle',
+ 'animateur',
+ '',
+ ]);
+ assert.deepEqual(textesCellule({ tour: 1, occupant: null, nom: null, titre: { libelle: 'hôte', pourvu: false } }, t), [
+ t('feuilles.tables.vide'),
+ 'hôte',
+ t('feuilles.tables.titreNonPourvu'),
+ ]);
+ assert.deepEqual(textesCellule({ tour: 2, occupant: 3, nom: 'Théo Pervenche', titre: null }, t), ['Théo Pervenche', '', '']);
+ });
+});
+
+describe('textesLigneAccueil : les textes d’une ligne de la liste d’accueil, dans l’ordre où elle les pose', () => {
+ test('le nom, le prénom, l’appartenance, puis le libellé de la place à chaque tour ; le texte vide pour un champ absent ou blanc', () => {
+ const places = [
+ { tour: 1, table: 2, siege: null },
+ { tour: 2, table: null, siege: null },
+ { tour: 3, table: 4, siege: 5 },
+ ];
+ assert.deepEqual(textesLigneAccueil({ participant: 1, nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles', places }, t), [
+ 'Ombrelle',
+ 'Iris',
+ 'Club des Merles',
+ t('feuilles.accueil.table', { table: 2 }),
+ t('feuilles.accueil.reserve'),
+ t('feuilles.accueil.tableSiege', { table: 4, siege: 5 }),
+ ]);
+ assert.deepEqual(textesLigneAccueil({ participant: 2, nom: 'Pervenche', prenom: null, appartenance: ' ', places: places.slice(1, 2) }, t), [
+ 'Pervenche',
+ '',
+ '',
+ t('feuilles.accueil.reserve'),
+ ]);
+ });
+});
diff --git a/src/interface/impression/pagination.js b/src/interface/impression/pagination.js
index 97e5b96..d4266b8 100644
--- a/src/interface/impression/pagination.js
+++ b/src/interface/impression/pagination.js
@@ -8,6 +8,13 @@
// 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.
+//
+// Une ligne plus haute qu'une page ne se coupe pas au bord de la page : son
+// texte se partage en morceaux qui tiennent chacun sur une page sous
+// l'en-tête (couperTexte), coupés au début d'un mot, et chaque morceau
+// prend une page à la suite (morcelerPages). couperLigne essaie les
+// morceaux sur une copie de la ligne rendue, hors de la vue ; trancher dit
+// ce que chaque texte de la ligne garde dans un morceau.
// 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
@@ -127,3 +134,197 @@ export function mesurerPlace(sonde, tableau) {
if (!(largeur > 0)) throw new RangeError(`mesurerPlace : tableau de largeur ${String(largeur)}`);
return (100 * (sonde.getBoundingClientRect().width + ARRONDI_PX)) / largeur;
}
+
+// Les débuts de caractère où un mot peut se couper : un point de code —
+// jamais entre les deux unités UTF-16 d'un caractère hors du plan de base —
+// qui n'est ni une marque combinante (sélecteurs de variante compris) ni un
+// liant sans chasse (U+200D), et ne suit pas un liant. Une coupe ne sépare
+// donc ni une lettre de son accent, ni les éléments d'une suite liée. L'expression ne
+// dépend pas de la langue du système, que l'interface ne lit pas.
+const DEBUT_DE_CARACTERE = /(? Array.isArray(textes) && textes.every((texte) => typeof texte === 'string');
+
+/**
+ * Ce que chaque texte garde entre deux positions de leur suite mise bout à
+ * bout, en unités UTF-16 : la part de chacun qui tombe dans [debut, fin), le
+ * texte vide pour un texte qui n'y tombe pas.
+ *
+ * @param {string[]} textes
+ * @param {number} debut entier, de 0 à fin
+ * @param {number} fin entier, de debut à la longueur totale
+ * @returns {string[]} un par texte reçu
+ * @throws {TypeError} des textes qui ne sont pas une liste de chaînes
+ * @throws {RangeError} des positions hors du texte, ou dans le désordre
+ */
+export function trancher(textes, debut, fin) {
+ if (!sontDesTextes(textes)) throw new TypeError('trancher : une liste de textes attendue');
+ const total = textes.reduce((somme, texte) => somme + texte.length, 0);
+ if (!Number.isSafeInteger(debut) || !Number.isSafeInteger(fin) || debut < 0 || fin < debut || fin > total) {
+ throw new RangeError(`trancher : [${String(debut)}, ${String(fin)}) hors de [0, ${total}]`);
+ }
+ let origine = 0;
+ return textes.map((texte) => {
+ const part = texte.slice(Math.max(0, debut - origine), Math.max(0, fin - origine));
+ origine += texte.length;
+ return part;
+ });
+}
+
+// Les positions où un morceau peut finir, croissantes, dans ]0, total[ :
+// mots, le début de chaque texte et de chaque mot ; caracteres, en plus,
+// chaque début de caractère (DEBUT_DE_CARACTERE).
+function coupesPossibles(textes) {
+ const mots = new Set();
+ const caracteres = new Set();
+ let origine = 0;
+ for (const texte of textes) {
+ mots.add(origine);
+ caracteres.add(origine);
+ for (const { index } of texte.matchAll(/(?<=\s)\S/gu)) mots.add(origine + index);
+ for (const { index } of texte.matchAll(DEBUT_DE_CARACTERE)) caracteres.add(origine + index);
+ origine += texte.length;
+ }
+ const croissantes = (positions) => [...positions].filter((p) => p > 0 && p < origine).sort((a, b) => a - b);
+ return { mots: croissantes(mots), caracteres: croissantes(caracteres), total: origine };
+}
+
+// La plus grande des positions (croissantes) de ]debut, borne[ où le
+// morceau [debut, position) tient encore, par dichotomie — tient croît
+// avec le morceau ; null quand aucune ne tient.
+function plusGrandeQuiTient(positions, debut, borne, tient) {
+ const candidates = positions.filter((p) => p > debut && p < borne);
+ let bas = 0;
+ let haut = candidates.length;
+ while (bas < haut) {
+ const milieu = (bas + haut) >> 1;
+ if (tient(debut, candidates[milieu])) bas = milieu + 1;
+ else haut = milieu;
+ }
+ return bas === 0 ? null : candidates[bas - 1];
+}
+
+/**
+ * Les bornes des morceaux d'un texte trop haut pour une page, ses textes mis
+ * bout à bout : [0, c₁, …, total], chaque morceau [cᵢ, cᵢ₊₁) le plus long
+ * qui tient (tient), à partir de la fin du précédent. Un morceau finit au
+ * début d'un mot ou d'un texte ; un mot qui ne tient pas seul se coupe au
+ * dernier début de caractère qui tient (DEBUT_DE_CARACTERE), et le morceau
+ * suivant reprend aux mots. Quand rien ne tient, un morceau prend un
+ * caractère : le texte avance toujours. Un texte vide donne [0, 0].
+ *
+ * La recherche est dichotomique : tient doit croître avec le morceau — un
+ * morceau qui tient, raccourci par la fin, tient encore.
+ *
+ * @param {string[]} textes
+ * @param {(debut: number, fin: number) => boolean} tient
+ * @returns {number[]}
+ * @throws {TypeError} des textes qui ne sont pas une liste de chaînes, un
+ * prédicat qui n'est pas une fonction
+ */
+export function couperTexte(textes, tient) {
+ if (!sontDesTextes(textes)) throw new TypeError('couperTexte : une liste de textes attendue');
+ if (typeof tient !== 'function') throw new TypeError('couperTexte : un prédicat tient attendu');
+ const { mots, caracteres, total } = coupesPossibles(textes);
+ const bornes = [0];
+ let debut = 0;
+ while (debut < total) {
+ let fin = total;
+ if (!tient(debut, total)) {
+ // Le premier mot qui suit borne le repli aux caractères : au-delà,
+ // un morceau finirait dans un mot que l'on pouvait garder entier.
+ const motSuivant = mots.find((p) => p > debut) ?? total;
+ fin =
+ plusGrandeQuiTient(mots, debut, total, tient) ??
+ plusGrandeQuiTient(caracteres, debut, motSuivant, tient) ??
+ caracteres.find((p) => p > debut) ??
+ total;
+ }
+ bornes.push(fin);
+ debut = fin;
+ }
+ if (total === 0) bornes.push(0);
+ return bornes;
+}
+
+/**
+ * Les pages d'un document dont les lignes seules sont continuées : chaque
+ * page seule (paginerFeuilles) dont la ligne a des bornes à plus d'un
+ * morceau (couperTexte) devient une page par morceau, sous la même ligne,
+ * morceau {debut, fin} ; toute autre page reste telle, morceau null. rang
+ * renumérote les pages du document, de 0.
+ *
+ * @param {Array<{rang: number, feuille: number, debut: number, fin: number, seule: boolean}>} pages
+ * @param {Array<{feuille: number, ligne: number, bornes: number[]}>} coupures
+ * @returns {Array<{rang: number, feuille: number, debut: number, fin: number, seule: boolean,
+ * morceau: {debut: number, fin: number}|null}>}
+ * @throws {RangeError} des bornes qui ne partent pas de 0 ou ne croissent pas strictement
+ */
+export function morcelerPages(pages, coupures) {
+ const resultat = [];
+ for (const page of pages) {
+ const coupure = page.seule ? coupures.find(({ feuille, ligne }) => feuille === page.feuille && ligne === page.debut) : undefined;
+ const bornes = coupure?.bornes;
+ if (bornes !== undefined) {
+ const croissantes = bornes.every((borne, rang) => Number.isSafeInteger(borne) && (rang === 0 ? borne === 0 : borne > bornes[rang - 1]));
+ if (bornes.length < 2 || !croissantes) throw new RangeError(`morcelerPages : bornes ${JSON.stringify(bornes)}`);
+ }
+ if (bornes === undefined || bornes.length === 2) {
+ resultat.push({ ...page, rang: resultat.length, morceau: null });
+ continue;
+ }
+ for (let rang = 1; rang < bornes.length; rang += 1) {
+ resultat.push({ ...page, rang: resultat.length, morceau: { debut: bornes[rang - 1], fin: bornes[rang] } });
+ }
+ }
+ return resultat;
+}
+
+/**
+ * Les bornes des morceaux d'une ligne plus haute qu'une page (couperTexte),
+ * lues sur le rendu : la ligne rendue dans sa liste, hors de la vue. Ses
+ * textes sont les nœuds de texte non blancs de ses cellules de données
+ * (td), dans l'ordre du document : une ligne de plusieurs cellules se
+ * continue cellule après cellule, et une page porte de chacune la part qui
+ * tombe dans son morceau (trancher). Les en-têtes de rangée (th) restent
+ * entiers sur chaque page. Un morceau tient quand une copie de la ligne, ses
+ * textes réduits à ce morceau, dans une copie du tableau à ses colonnes,
+ * mesure au plus hauteur ; la copie se retire avant le retour.
+ *
+ * @param {HTMLTableRowElement} ligne
+ * @param {number} hauteur en px CSS, ce que l'en-tête laisse d'une page
+ * @returns {number[]}
+ */
+export function couperLigne(ligne, hauteur) {
+ const tableau = ligne.closest('table');
+ const essai = tableau.cloneNode(false);
+ for (const colonnes of tableau.querySelectorAll(':scope > colgroup')) essai.append(colonnes.cloneNode(true));
+ const corps = document.createElement('tbody');
+ const copie = ligne.cloneNode(true);
+ corps.append(copie);
+ essai.append(corps);
+ // Les nœuds de texte non blancs des cellules de données d'une rangée,
+ // dans l'ordre du document.
+ const noeuds = (rangee) => {
+ const lus = [];
+ for (const cellule of rangee.cells) {
+ if (cellule.tagName !== 'TD') continue;
+ const parcours = document.createTreeWalker(cellule, NodeFilter.SHOW_TEXT);
+ for (let noeud = parcours.nextNode(); noeud !== null; noeud = parcours.nextNode()) if (noeud.data.trim() !== '') lus.push(noeud);
+ }
+ return lus;
+ };
+ const textes = noeuds(ligne).map((noeud) => noeud.data);
+ const copies = noeuds(copie);
+ tableau.after(essai);
+ try {
+ return couperTexte(textes, (debut, fin) => {
+ trancher(textes, debut, fin).forEach((part, rang) => {
+ copies[rang].data = part;
+ });
+ return copie.getBoundingClientRect().height <= hauteur + BRUIT;
+ });
+ } finally {
+ essai.remove();
+ }
+}
diff --git a/src/interface/impression/pagination.test.js b/src/interface/impression/pagination.test.js
index 7385799..c8f97f4 100644
--- a/src/interface/impression/pagination.test.js
+++ b/src/interface/impression/pagination.test.js
@@ -4,10 +4,12 @@
// 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.
+// plus haute qu'une page, une liste vide, plusieurs feuilles bout à bout ;
+// le texte d'une ligne plus haute qu'une page coupé en morceaux qui
+// tiennent chacun sur une page, entre les mots, et ses pages de suite.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
-import { mesurerPlace, paginer, paginerFeuilles } from './pagination.js';
+import { couperTexte, mesurerPlace, morcelerPages, paginer, paginerFeuilles, trancher } 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', () => {
@@ -129,3 +131,136 @@ describe('mesurerPlace : la part de la largeur d’une liste que prend son plus
assert.throws(() => mesurerPlace(boite(10), boite(0)), RangeError);
});
});
+
+describe('trancher : la part de chaque fragment entre deux positions du texte mis bout à bout', () => {
+ test('chaque fragment garde ce qui tombe entre debut et fin, le texte vide sinon', () => {
+ const textes = ['Iris Ombrelle', 'hôte', ''];
+ assert.deepEqual(trancher(textes, 0, 17), textes);
+ assert.deepEqual(trancher(textes, 0, 5), ['Iris ', '', '']);
+ assert.deepEqual(trancher(textes, 5, 15), ['Ombrelle', 'hô', '']);
+ assert.deepEqual(trancher(textes, 13, 17), ['', 'hôte', '']);
+ assert.deepEqual(trancher(textes, 17, 17), ['', '', '']);
+ });
+
+ test('des positions hors du texte ou dans le désordre lèvent RangeError ; des fragments qui ne sont pas des textes, TypeError', () => {
+ for (const [debut, fin] of [[-1, 3], [3, 2], [0, 18], [0.5, 2], [Number.NaN, 2]]) {
+ assert.throws(() => trancher(['Iris Ombrelle', 'hôte'], debut, fin), RangeError, `${debut}..${fin}`);
+ }
+ assert.throws(() => trancher('Iris', 0, 1), TypeError);
+ assert.throws(() => trancher(['Iris', null], 0, 1), TypeError);
+ });
+});
+
+describe('couperTexte : les bornes des morceaux d’un texte trop haut pour une page', () => {
+ // Un prédicat qui tient tout morceau d'au plus n unités, et le relevé de
+ // ses appels.
+ function auPlus(n) {
+ const appels = [];
+ const tient = (debut, fin) => {
+ appels.push([debut, fin]);
+ return fin - debut <= n;
+ };
+ return { tient, appels };
+ }
+
+ test('un texte qui tient d’un bloc donne un seul morceau', () => {
+ assert.deepEqual(couperTexte(['Iris Ombrelle', 'hôte'], () => true), [0, 17]);
+ });
+
+ test('les morceaux se coupent au début d’un mot, le plus long qui tient ; le dernier finit le texte', () => {
+ const { tient } = auPlus(20);
+ const textes = ['Ombrelle Pervenche Ombrelle Pervenche'];
+ const bornes = couperTexte(textes, tient);
+ assert.deepEqual(bornes, [0, 19, 37]);
+ assert.deepEqual(
+ bornes.slice(1).map((fin, rang) => trancher(textes, bornes[rang], fin)[0]),
+ ['Ombrelle Pervenche ', 'Ombrelle Pervenche'],
+ );
+ });
+
+ test('le début d’un fragment est une coupe : le titre passe entier sur la page suivante', () => {
+ assert.deepEqual(couperTexte(['Iris Ombrelle', 'hôte'], auPlus(13).tient), [0, 13, 17]);
+ });
+
+ test('un mot plus long qu’une page se coupe entre deux caractères : jamais avant un accent combinant, dans un caractère ou après un liant', () => {
+ assert.deepEqual(couperTexte(['Ombrelle'.repeat(5)], auPlus(15).tient), [0, 15, 30, 40]);
+ // Dix e à accent combinant, deux unités chacun : une coupe à 5 tomberait
+ // entre la lettre et sa marque.
+ assert.deepEqual(couperTexte(['e\u{301}'.repeat(10)], auPlus(5).tient), [0, 4, 8, 12, 16, 20]);
+ // Un caractère hors du plan de base, deux unités UTF-16.
+ assert.deepEqual(couperTexte(['\u{1F33C}'.repeat(3)], auPlus(3).tient), [0, 2, 4, 6]);
+ // Deux suites liées par U+200D, cinq unités chacune, plus longues que le
+ // morceau : chacune reste entière, ni coupée avant le liant ni après.
+ assert.deepEqual(couperTexte(['\u{1F33C}\u{200D}\u{1F33C}'.repeat(2)], auPlus(4).tient), [0, 5, 10]);
+ });
+
+ test('un mot coupé reprend aux mots : la coupe revient au début du mot suivant dès qu’elle tient', () => {
+ // « OmbrelleOmbrelle » passe la page de 10 : coupé à 10 ; le morceau
+ // suivant finit au début de « Pervenche ».
+ assert.deepEqual(couperTexte(['OmbrelleOmbrelle Pervenche'], auPlus(10).tient), [0, 10, 17, 26]);
+ });
+
+ test('rien ne tient : chaque morceau prend un caractère, le texte avance toujours', () => {
+ assert.deepEqual(couperTexte(['abc'], () => false), [0, 1, 2, 3]);
+ });
+
+ test('la recherche est dichotomique : quelques sondes par morceau, non une par mot', () => {
+ const { tient, appels } = auPlus(200);
+ const texte = 'Ombrelle Pervenche '.repeat(1500).trim();
+ const bornes = couperTexte([texte], tient);
+ assert.equal(bornes[0], 0);
+ assert.equal(bornes.at(-1), texte.length);
+ assert.ok(bornes.length - 1 >= Math.ceil(texte.length / 200));
+ for (let rang = 1; rang < bornes.length; rang += 1) {
+ assert.ok(bornes[rang] - bornes[rang - 1] <= 200, `morceau ${rang}`);
+ // Chaque coupe tombe au début d'un mot, et le mot suivant n'y tenait pas.
+ if (rang < bornes.length - 1) {
+ assert.match(texte.slice(bornes[rang]), /^Ombrelle|^Pervenche/, `coupe ${rang}`);
+ const suivant = texte.indexOf(' ', bornes[rang]) + 1;
+ assert.ok(suivant - bornes[rang - 1] > 200, `coupe ${rang} : le mot suivant tenait`);
+ }
+ }
+ assert.ok(appels.length <= 20 * (bornes.length - 1), `${appels.length} sondes pour ${bornes.length - 1} morceaux`);
+ });
+
+ test('un texte vide donne un seul morceau vide ; des fragments qui ne sont pas des textes lèvent TypeError', () => {
+ assert.deepEqual(couperTexte(['', ''], () => false), [0, 0]);
+ assert.throws(() => couperTexte([3], () => true), TypeError);
+ assert.throws(() => couperTexte(['Iris'], null), TypeError);
+ });
+});
+
+describe('morcelerPages : une ligne seule, continuée sur autant de pages que de morceaux', () => {
+ test('chaque morceau d’une ligne seule prend une page, sous le même rang de ligne ; les autres pages restent, renumérotées', () => {
+ const pages = paginerFeuilles(
+ [
+ { entete: 10, lignes: [20, 300, 20] },
+ { entete: 10, lignes: [300] },
+ ],
+ 100,
+ );
+ assert.deepEqual(morcelerPages(pages, [{ feuille: 0, ligne: 1, bornes: [0, 40, 80, 95] }]), [
+ { rang: 0, feuille: 0, debut: 0, fin: 1, seule: false, morceau: null },
+ { rang: 1, feuille: 0, debut: 1, fin: 2, seule: true, morceau: { debut: 0, fin: 40 } },
+ { rang: 2, feuille: 0, debut: 1, fin: 2, seule: true, morceau: { debut: 40, fin: 80 } },
+ { rang: 3, feuille: 0, debut: 1, fin: 2, seule: true, morceau: { debut: 80, fin: 95 } },
+ { rang: 4, feuille: 0, debut: 2, fin: 3, seule: false, morceau: null },
+ { rang: 5, feuille: 1, debut: 0, fin: 1, seule: true, morceau: null },
+ ]);
+ });
+
+ test('sans coupure, ou d’un seul morceau, une ligne seule garde sa page ; une coupure d’une ligne qui n’est pas seule ne change rien', () => {
+ const pages = paginerFeuilles([{ entete: 10, lignes: [20, 300] }], 100);
+ const attendues = pages.map((page) => ({ ...page, morceau: null }));
+ assert.deepEqual(morcelerPages(pages, []), attendues);
+ assert.deepEqual(morcelerPages(pages, [{ feuille: 0, ligne: 1, bornes: [0, 95] }]), attendues);
+ assert.deepEqual(morcelerPages(pages, [{ feuille: 0, ligne: 0, bornes: [0, 5, 9] }]), attendues);
+ });
+
+ test('des bornes qui ne partent pas de 0 ou ne croissent pas lèvent RangeError', () => {
+ const pages = paginerFeuilles([{ entete: 10, lignes: [300] }], 100);
+ for (const bornes of [[1, 5], [0, 5, 5], [0, 7, 3], [], [0, 2.5]]) {
+ assert.throws(() => morcelerPages(pages, [{ feuille: 0, ligne: 0, bornes }]), RangeError, JSON.stringify(bornes));
+ }
+ });
+});
diff --git a/src/interface/parcours.navigateur.test.js b/src/interface/parcours.navigateur.test.js
index da36e74..09173c8 100644
--- a/src/interface/parcours.navigateur.test.js
+++ b/src/interface/parcours.navigateur.test.js
@@ -17,7 +17,8 @@
// chargée, 11, 19, 20, puis 22 et 23, l'aperçu avant impression d'une
// proposition et du retenu, puis 24 et 25, la feuille de contrôle et la
// première planche du PDF que la section Badges produit, tramées sous node
-// (§ 19.6) —, puis l'ordre de lecture du § 2.1 sur
+// (§ 19.6) —, ces sorties en lecture puis sur un plan bloqué, qui n'écrivent
+// que la planche et reglages_locaux.json (§ 11.6), puis l'ordre de lecture du § 2.1 sur
// l'événement retenu, les fenêtres — aucune pendant un geste non
// destructeur, une qui nomme ce qui part pour chaque geste qui détruit —,
// enfin chaque famille de geste modifiant en lecture puis sur un plan
@@ -716,6 +717,60 @@ async function photographierPdf(octets, { page, fichier }, conditions) {
capturesPdf.add(fichier);
}
+// --- Sortir sans modifier (§ 11.6, § 8.4) ------------------------------------------
+
+// Les sorties d'un événement ouvert : l'aperçu de chaque vue, envoyé à
+// l'imprimante d'épreuve ; le papier de la machine changé, puis rendu ;
+// la planche de badges produite par la section Badges. Le parcours
+// reprend sur le plan. Rend le nom du PDF écrit.
+async function sortir() {
+ const impressions = m.imprimante.appels().length;
+ const rang = m.trace.length;
+ await ouvrirSection('plan');
+ await cliquer('[data-ecran="evenement"] [data-commande="apercu"]');
+ await attendre('le document imprimé', () => dans('[data-impression] [data-feuille]'));
+ for (const vue of ['plan', 'accueil', 'tables']) {
+ await cliquer(`[data-ecran="apercu"] [data-commande="vue"][data-cible="${vue}"]`);
+ await attendre(`la vue ${vue}`, () => dans(`[data-impression][data-vue-impression="${vue}"] [data-feuille]`));
+ await cliquer('[data-ecran="apercu"] [data-commande="imprimer"]');
+ }
+ await attendre('trois impressions', () => m.imprimante.appels().length === impressions + 3);
+ for (const papier of ['Lettre', 'A4']) {
+ await cliquer('[data-ecran="apercu"] [data-papier="choisi"] [data-commande="changerPapier"]');
+ await attendre('la question du papier', () => dans('[data-ecran="apercu"] [data-papier="question"]'));
+ await cliquer(`[data-papier="question"] [data-cible="${papier}"] [data-commande="choisirPapier"]`);
+ await attendre(`le papier ${papier}`, () => etat().papier === papier && dans('[data-papier="choisi"]') && dans('[data-impression] [data-feuille]'));
+ }
+ await cliquer('[data-ecran="apercu"] [data-commande="fermerApercu"]');
+ await attendre('l’événement, de retour', () => dans('[data-ecran="evenement"]'));
+ await ouvrirSection('badges');
+ await cliquer('[data-section="badges"] [data-commande="produirePlanche"]');
+ await attendre('la planche produite', () => dans('[data-section="badges"] [data-chemin-planche]'), IMAGES_PLANCHE);
+ const planches = cheminsEcrits(rang).filter((chemin) => chemin.endsWith('.pdf'));
+ expect(planches, 'une planche écrite').toHaveLength(1);
+ await ouvrirSection('plan');
+ return planches[0];
+}
+
+// « Regarder n'écrit jamais » (§ 8.2, § 8.4, § 11.6) : les deux écritures
+// que regarder et sortir admettent — reglages_locaux.json, où s'écrivent
+// le cadrage différé et le papier de la machine, et la planche de
+// l'événement, .badges…pdf. Rien d'autre.
+const ecritureAdmise = (chemin) =>
+ chemin === 'reglages_locaux.json' || (chemin.startsWith(`${base()}.badges`) && chemin.endsWith('.pdf'));
+
+// Sort, puis affirme que seules les deux écritures admises ont eu lieu,
+// chacune au moins une fois : rien de l'événement.
+async function sortirSansEcrire(avant, empreinte, geste) {
+ const rang = m.trace.length;
+ const planche = await sortir();
+ const interdites = m.trace.slice(rang).filter(([, , chemin]) => chemin !== '' && !ecritureAdmise(chemin));
+ expect(interdites, geste).toEqual([]);
+ expect(cheminsEcrits(rang).sort(), geste).toEqual([planche, 'reglages_locaux.json'].sort());
+ expect(serialiserCharge(charge())).toBe(empreinte);
+ await rienEcrit(avant, geste);
+}
+
// --- Le parcours (§ 2.1, § 19.3) ------------------------------------------------------
describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE }, () => {
@@ -1529,15 +1584,58 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
await ouvrirSection('plan');
});
- // Un échec de 22 à 25 laisse l'aperçu ouvert, la proposition montrée, la
- // section Badges ouverte ou la fenêtre défilée : l'état est rendu ici,
- // pour que les épreuves suivantes jugent leurs propres gardes et non ce
- // reste.
+ // Les sorties vivent en lecture et sur un plan bloqué, et n'écrivent que
+ // la planche et le papier de la machine (§ 11.6, § 8.4, § 9) ; le plan
+ // débloqué, le parcours reprend sur le retenu, en écriture.
+ test('lecture, puis bloqué : aperçu, impression, papier et planche ; seuls le PDF et reglages_locaux.json s’écrivent', async () => {
+ expect(charge().evenement.etat).toBe('retenu');
+ await cliquer('[data-place-fixe] [data-commande="lecture"]');
+ await attendre('la lecture', () => etat().mode === 'lecture');
+ await sortirSansEcrire(fichiers(), serialiserCharge(charge()), 'lecture, sortir');
+
+ await cliquer('[data-place-fixe] [data-commande="modifier"]');
+ await attendre('l’écriture', () => etat().mode === 'ecriture');
+ let r = revision();
+ await cliquer('[data-etat="bloque"] [data-commande="etat"]');
+ await uneEntree(r, 'bloquer');
+ expect(charge().evenement.etat).toBe('bloque');
+ await sortirSansEcrire(fichiers(), serialiserCharge(charge()), 'bloqué, sortir');
+
+ r = revision();
+ // « Débloquer » se confirme sur place : armée au premier appui, la
+ // commande exécute au second.
+ await cliquer('[data-place-fixe] [data-commande="debloquer"]');
+ await cliquer('[data-place-fixe] [data-commande="debloquer"]');
+ await uneEntree(r, 'débloquer');
+ expect(charge().evenement.etat).toBe('retenu');
+ if (etat().mode !== 'ecriture') {
+ await cliquer('[data-place-fixe] [data-commande="modifier"]');
+ await attendre('l’écriture', () => etat().mode === 'ecriture');
+ }
+ });
+
+ // Un échec de 22 aux sorties en lecture laisse l'aperçu ouvert, la
+ // proposition montrée, la section Badges ouverte, la fenêtre défilée, le
+ // plan bloqué ou en lecture : l'état est rendu ici, pour que les épreuves
+ // suivantes jugent leurs propres gardes et non ce reste.
afterAll(async () => {
if (dans('[data-ecran="apercu"] [data-commande="fermerApercu"]') !== null) {
await cliquer('[data-ecran="apercu"] [data-commande="fermerApercu"]');
await attendre('le plan, de retour', () => dans('[data-ecran="evenement"]'));
}
+ if (etat().mode === 'lecture' && dans('[data-place-fixe] [data-commande="modifier"]') !== null) {
+ await cliquer('[data-place-fixe] [data-commande="modifier"]');
+ await attendre('l’écriture', () => etat().mode === 'ecriture');
+ }
+ if (charge().evenement.etat === 'bloque' && dans('[data-place-fixe] [data-commande="debloquer"]') !== null) {
+ await cliquer('[data-place-fixe] [data-commande="debloquer"]');
+ await cliquer('[data-place-fixe] [data-commande="debloquer"]');
+ await attendre('le plan débloqué', () => charge().evenement.etat !== 'bloque');
+ }
+ if (etat().mode === 'lecture' && dans('[data-place-fixe] [data-commande="modifier"]') !== null) {
+ await cliquer('[data-place-fixe] [data-commande="modifier"]');
+ await attendre('l’écriture', () => etat().mode === 'ecriture');
+ }
if (m.ui.etat().proposition !== null && dans('[data-commande="quitterProposition"]') !== null) {
await cliquer('[data-commande="quitterProposition"]');
await attendre('le retenu montré', () => m.ui.etat().proposition === null);
@@ -1928,9 +2026,9 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', {
await oublierChoix();
}
- // Les écritures relevées depuis le rang donné, hors du cadrage qui s'écrit
- // différé dans reglages_locaux.json (contrainte 4).
- const ecrituresDepuis = (rang) => m.trace.slice(rang).filter(([, , chemin]) => chemin !== '' && !chemin.endsWith('reglages_locaux.json'));
+ // Les écritures relevées depuis le rang donné, hors des deux que
+ // regarder admet (ecritureAdmise).
+ const ecrituresDepuis = (rang) => m.trace.slice(rang).filter(([, , chemin]) => chemin !== '' && !ecritureAdmise(chemin));
test('en lecture : quatorze familles refusées avec « Modifier », rien d’écrit ; tri, filtre, listes de travail vivants', async () => {
await cliquer('[data-place-fixe] [data-commande="lecture"]');
@@ -1945,6 +2043,7 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', {
await regarderReste();
expect(serialiserCharge(charge())).toBe(empreinte);
await rienEcrit(avant, 'lecture, regarder');
+ expect(ecrituresDepuis(rang)).toEqual([]);
});
test('plan bloqué : quatorze familles refusées avec « Débloquer », rien d’écrit ; tri, filtre, listes de travail vivants', async () => {
@@ -1965,6 +2064,7 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', {
await regarderReste();
expect(serialiserCharge(charge())).toBe(empreinte);
await rienEcrit(avant, 'bloqué, regarder');
+ expect(ecrituresDepuis(rang)).toEqual([]);
});
});
|