[FIX] badges: injective sheet file names, a reissue never overwrites

Two propositions with the same timestamp, or the by-table order, gave the
same file name, so one badge sheet silently replaced another. The name now
carries -p<id> for a proposition and _table for the by-table order; the
unmarked retained alphabetical name is unchanged. A reissue, or a name cut
to fit under a long folder, takes the first free rank and never replaces a
file; a folder that cannot be listed refuses first. A faulty proposition is
refused with its own message. The control sheet names its source.
Checked: node 3896, browser 767, long 105; captures 24 and 25 republished.

--- FR ---

[FIX] badges : noms de planche injectifs, une réédition n'écrase rien

Deux propositions au même horodatage, ou l'ordre par table, donnaient le
même nom : une planche en remplaçait une autre sans un mot. Le nom porte
-p<id> pour une proposition et _table pour l'ordre par table ; le nom du
retenu alphabétique ne change pas. Une réédition, ou un nom raccourci sous
un dossier long, prend le premier rang libre et n'écrase rien ; un dossier
qui ne se liste pas refuse d'abord. Une proposition fautive a son refus.
La feuille de contrôle nomme sa source.
Vérifié : node 3896, navigateur 767, long 105 ; captures 24 et 25 republiées.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-10 20:09:25 -04:00
parent 3db0bb138c
commit 7c9b708cc9
14 changed files with 699 additions and 112 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

62
spec.md
View file

@ -2327,8 +2327,10 @@ sur une page ; **elle ne se découpe pas**, ses pages de suite non plus. Elle
porte une **échelle graphique de 100 mm, repérée tous les 10 mm**, sur sa
première page, les dimensions annoncées du badge, le format de papier,
l'imposition retenue, le nombre de planches, le compte de badges produits et le
compte attendu, le nom et la date de l'événement, l'horodatage du placement, la
version (§ 18.6), la consigne d'impression — recto seul, à 100 % —, les
compte attendu, le nom et la date de l'événement, la source — le placement
retenu, ou la proposition imprimée par son numéro, que l'horodatage ne
distingue pas des autres propositions de sa génération —, l'horodatage du
placement, la version (§ 18.6), la consigne d'impression — recto seul, à 100 % —, les
caractères que la police n'a pas, par leur point de code (§ 11.5), et le
filigrane BROUILLON s'il y a lieu, sur chacune de ses pages. L'opérateur la
mesure sous une règle avant d'engager la
@ -2498,20 +2500,51 @@ la fin de la pile finale, ce qui est la propriété qui compte.
Les deux impositions produisent la même pile finale par deux gestes différents ;
prendre l'une pour l'autre oblige à retrier la totalité. Le logiciel inscrit
l'imposition retenue **dans le nom du fichier produit**, avec le nom de
l'événement, le format de papier et l'horodatage du placement, parce que c'est la
seule chose qui survit à l'envoi de la planche à quelqu'un d'autre. Le nom suit
un gabarit fixe :
l'événement, le format de papier, la source, l'ordre et l'horodatage du
placement, parce que c'est la seule chose qui survit à l'envoi de la planche à
quelqu'un d'autre. Le nom suit un gabarit fixe :
```
<base>.badges[-<k>]_<A4|Lettre>_<feuille|pile>_<AAAA-MM-JJ_HH-MM-SS>.pdf
<base>.badges[-p<id>][-<k>]_<A4|Lettre>_<feuille|pile>[_table]_<AAAA-MM-JJ_HH-MM-SS>[_<n>].pdf
```
— la base des fichiers de l'événement (§ 8.6), le nombre k de badges d'une
réédition, le papier, l'imposition, puis le jour et l'heure de l'horodatage du
placement, décalage retiré, ou `horodatage-inconnu` quand le placement n'en
porte pas. Il ne contient aucun deux-points, que Windows refuse, et tient sous la
borne de chemin du § 8.6 ; même événement, même papier, même imposition, même
placement : même nom.
— la base des fichiers de l'événement (§ 8.6), raccourcie et suivie d'un point
quand le chemin dépasserait la borne (ci-dessous) ; l'identifiant de la proposition
imprimée, rien pour le retenu ; le nombre k de badges d'une réédition ; le
papier ; l'imposition ; `_table` pour l'ordre par table, rien pour l'ordre
alphabétique ; le jour et l'heure de l'horodatage du placement, décalage
retiré, ou `horodatage-inconnu` quand le placement n'en porte pas ; enfin le
rang n d'un nom déjà pris dans le dossier. Il ne contient aucun deux-points,
que Windows refuse.
**Le nom est injectif sur ce qui change la pile.** Une génération date toutes
les propositions qu'elle ajoute du même instant (§ 8.9) : sans leur
identifiant, deux propositions donneraient le même nom, et la seconde planche
remplacerait la première sans rien dire. L'ordre par table change la pile
autant que l'imposition, et se lit donc dans le nom de la même façon. Même
événement, même source, même papier, même imposition, même ordre, même
placement : même nom, et la planche entière remplace la sienne, qui imprime la
même pile. **Une réédition ne remplace jamais un fichier** : deux rééditions de
trois personnes différentes partagent tout le reste du nom, et la seconde prend
le premier rang libre, `_2`, `_3`…, comme le dossier daté de la corbeille
(§ 8.7). Une planche dont la base se raccourcit (ci-dessous) prend aussi le
premier rang libre : la base d'un événement et celle de sa copie (§ 8.7), qui
partagent propositions et horodatages, peuvent se raccourcir en une même tête.
Un dossier qui ne se liste pas refuse ces planches plutôt que de risquer un
remplacement, et offre « Enregistrer sous… ».
**Le nom tient sous la borne de chemin du § 8.6.** Sans marque, son plus long
suffixe tient dans la réserve que la borne fait à chaque base. Les marques et le
rang l'allongent : quand le chemin complet dépasserait la borne, la base se
raccourcit dans le nom de la planche, sans couper un caractère, espaces et points
finaux retirés, sans laisser un nom réservé (§ 8.6), suivie d'un point ; le reste
du nom demeure entier. Aucune base ne finit par un point, que Windows retire de
la fin d'un nom et que la dérivation retire donc aussi (§ 8.6) : un nom
raccourci ne se confond avec aucun nom entier, et la planche entière d'une base
qui tient ne remplace jamais une planche raccourcie. Le nom raccourci dépend de
la racine du dossier qui le reçoit. Quand même un caractère de la base ne tient
pas, le nom garde sa base, l'écriture refusée se dit, et « Enregistrer sous… »
dépose la planche ailleurs.
**Sous `electron`, le fichier se dépose à côté des fichiers d'événement** (§ 8.6),
par une écriture atomique, et le logiciel en nomme le chemin ; une écriture
@ -2524,7 +2557,10 @@ nomme le fichier, sans chemin.
proposition choisie dans la section des propositions ; l'annonce nomme la source.
Une planche tirée d'une proposition sort **toujours** en BROUILLON (§ 11.7), à
l'horodatage de cette proposition : elle n'est pas le plan retenu. Sans retenu ni
proposition désignée, la production se refuse et renvoie aux propositions.
proposition désignée, la production se refuse et renvoie aux propositions. Une
proposition qui se contredit se refuse aussi, et renvoie aux propositions : la
version précédente du fichier, le remède d'un retenu qui se contredit, ne se
reprend pas pour elle, puisque le retenu est sain.
**Produire la planche n'est pas une modification.** Elle n'écrit aucun fichier
d'événement et n'ajoute aucune entrée d'historique (§ 8.2), donc elle reste

View file

@ -542,6 +542,7 @@ const EXEMPLES = new Map([
['ENTREE_CHANGER_CIBLE_REDONDANCE', { cible: 1200 }],
// Planche de badges
['PLANCHE_SANS_PLACEMENT', { remede: { geste: 'ouvrirSection', section: 'propositions' } }],
['PROPOSITION_FAUTIVE', { id: 2, remede: { geste: 'ouvrirSection', section: 'propositions' } }],
[
'PLANCHE_DERIVE',
{

View file

@ -50,6 +50,12 @@ export const TABLE = new Map([
'PLANCHE_SANS_PLACEMENT',
() => "Aucun placement à imprimer : retenez une proposition, ou choisissez-en une parmi les propositions.",
],
[
'PROPOSITION_FAUTIVE',
({ id }) =>
`La proposition n° ${id} se contredit, la planche ne se produit pas : ` +
'choisissez-en une autre parmi les propositions, ou relancez la génération.',
],
[
'PLANCHE_DERIVE',
({ raisons }, texte) =>
@ -126,6 +132,16 @@ export const TABLE = new Map([
(exclus === 0 ? '' : `, ${compte(exclus, 'exclu', 'exclus')}`),
],
['planche.controle.evenement', (evenement, texte) => `Événement : ${texte('planche.pied.evenement', evenement)}`],
// La source imprimée : le placement retenu, ou la proposition par son
// numéro, que rien d'autre sur la page ne distingue d'une autre proposition
// de la même génération.
[
'planche.controle.source',
({ source, proposition }) =>
source === 'proposition'
? `Source : la proposition n° ${proposition}, qui n\u{2019}est pas le placement retenu`
: 'Source : le placement retenu',
],
['planche.controle.version', ({ version }) => `Version : ${version}`],
['planche.controle.consigne', 'Imprimer en recto seul, à 100 %'],
['planche.controle.reedition', ({ n }) => `Réédition : ${compte(n, 'badge', 'badges')}`],

View file

@ -11,9 +11,10 @@
// en page et le PDF (src/pdf) ne composent aucun mot.
//
// La planche imprime une source : le placement retenu, ou une proposition
// désignée par son identifiant (Q3), qui sort toujours en BROUILLON. Elle se
// refuse sans placement, sur un placement qui se contredit ou qui ne décrit
// plus la soirée, avant de produire (§ 11.8).
// désignée par son identifiant (Q3), qui sort toujours en BROUILLON, et que
// la feuille de contrôle nomme. Elle se refuse sans placement, sur un
// placement qui se contredit ou qui ne décrit plus la soirée, avant de
// produire (§ 11.8).
import { pourvoyeursDesTitres } from '../csv/import.js';
import { cleNormalisee } from '../csv/normalisation.js';
import { BADGE, PAPIERS } from '../geometrie/papiers.js';
@ -46,6 +47,9 @@ import { versionVisible } from './version.js';
* pied: string[], piedReduit: string[]}} textes
* @typedef {Object} ModelePlanche
* @property {Badge[]} badges dans l'ordre de la pile
* @property {'retenu'|'proposition'} source le placement imprimé
* @property {number|null} proposition l'identifiant de la proposition
* imprimée ; null pour le retenu
* @property {{nom: string, date: string|null}} evenement
* @property {string|null} horodatage
* @property {boolean} brouillon
@ -149,11 +153,14 @@ function placementDeSource(charge, source, proposition) {
/**
* Le refus qui garde la planche avant de produire (§ 11.8, Q3), le premier
* dans cet ordre : PLANCHE_SANS_PLACEMENT {} — la source retenu sans retenu,
* ou la source proposition sans proposition désignée ; RETENU_FAUTIF {},
* relayé tel que les places le lèvent, quand le placement de la source se
* contredit ; PLANCHE_DERIVE {raisons}, les raisons de dérive de la source
* nommées (raisonsNommees) quand elle ne décrit plus la soirée. null quand
* la planche se produit. Les détails portent le remède.
* ou la source proposition sans proposition désignée ; quand le placement
* de la source se contredit, RETENU_FAUTIF {}, relayé tel que les places le
* lèvent, remède la version précédente du fichier, pour le retenu, et
* PROPOSITION_FAUTIVE {id}, remède les propositions, pour une proposition —
* le retenu, sain, ne se reprend pas pour elle ; PLANCHE_DERIVE {raisons},
* les raisons de dérive de la source nommées (raisonsNommees) quand elle ne
* décrit plus la soirée. null quand la planche se produit. Les détails
* portent le remède.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {{source?: 'retenu'|'proposition', proposition?: number|null}} [options]
@ -166,6 +173,7 @@ export function refusPlanche(charge, { source = 'retenu', proposition = null } =
const placement = placementDeSource(charge, source, proposition);
if (placement === null) return { code: 'PLANCHE_SANS_PLACEMENT', details: { remede: versPropositions() } };
if (fautes(placement).length > 0) {
if (source === 'proposition') return { code: 'PROPOSITION_FAUTIVE', details: { id: proposition, remede: versPropositions() } };
return { code: 'RETENU_FAUTIF', details: { remede: { geste: 'ouvrirSecours', source: 'precedent' } } };
}
const raisons = derive(placement, charge);
@ -308,7 +316,8 @@ function selectionLue(charge, selection) {
* Le modèle de la planche (§ 11.4 à § 11.8) : un badge par personne non
* exclue — ou par personne de la sélection, pour une réédition —, dans
* l'ordre demandé, ses lignes de tour lues du placement de la source, ses
* textes composés. horodatage : horodatagePlacement de la source ;
* textes composés. source et proposition : le placement imprimé, que la
* feuille de contrôle nomme ; horodatage : horodatagePlacement de la source ;
* brouillon : estBrouillon sur toute la soirée, toujours vrai pour une
* proposition ; version : versionVisible, que la feuille de contrôle
* imprime et le badge jamais. Les comptes sont ceux de la soirée, produits
@ -368,6 +377,8 @@ export function modelePlanche(
const exclus = charge.participants.filter(({ exclu }) => exclu).length;
return {
badges,
source,
proposition: source === 'proposition' ? proposition : null,
evenement,
horodatage,
brouillon: estBrouillon(charge, { source, tour: 'tous' }),
@ -389,7 +400,8 @@ const sansAbsents = (texte, absents) => [...texte].map((c) => (absents.has(c) ?
* Les lignes de la feuille de contrôle (§ 11.3, § 11.5, § 11.8), page 1 de
* la planche : les cotes du badge, le papier et ses cotes, l'imposition, le
* nombre de planches, le compte produit et le compte attendu, l'événement
* et sa date, l'horodatage du placement, la version, la consigne
* et sa date, la source — le placement retenu, ou la proposition par son
* numéro —, l'horodatage du placement, la version, la consigne
* d'impression ; BROUILLON s'il y a lieu ; pour une réédition, son compte
* puis les noms réédités dans l'ordre de la pile ; les caractères absents
* de la police, quand il y en a, par leur point de code. Dans toutes les
@ -402,7 +414,8 @@ const sansAbsents = (texte, absents) => [...texte].map((c) => (absents.has(c) ?
* @param {number} options.planches entier ≥ 0
* @param {string[]} [options.absents] un caractère chacun, doublons admis
* @returns {string[]}
* @throws {TypeError} une option hors de son domaine
* @throws {TypeError} une option hors de son domaine, une source du modèle
* hors de la sienne
*/
export function textesControle(modele, { papier, imposition, planches, absents = [] }) {
if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) {
@ -418,7 +431,12 @@ export function textesControle(modele, { papier, imposition, planches, absents =
if (!Array.isArray(absents) || !absents.every(unCaractere)) {
throw new TypeError(`absents : caractères attendus, reçu ${JSON.stringify(absents)}`);
}
const { badges, evenement, horodatage, brouillon, comptes, reedition, version } = modele;
const { badges, source, proposition, evenement, horodatage, brouillon, comptes, reedition, version } = modele;
const sourceBienFormee =
(source === 'retenu' && proposition === null) || (source === 'proposition' && Number.isSafeInteger(proposition));
if (!sourceBienFormee) {
throw new TypeError(`modele : source et proposition attendues, reçu ${JSON.stringify({ source, proposition })}`);
}
const lignes = [
libelle('planche.controle.badge', BADGE),
libelle('planche.controle.papier', { papier, ...PAPIERS[papier] }),
@ -426,6 +444,7 @@ export function textesControle(modele, { papier, imposition, planches, absents =
libelle('planche.controle.planches', { planches }),
libelle('planche.controle.comptes', comptes),
libelle('planche.controle.evenement', evenement),
libelle('planche.controle.source', { source, proposition }),
horodatage === null ? libelle('planche.pied.horodatageInconnu') : libelle('planche.pied.horodatage', { horodatage }),
libelle('planche.controle.version', { version }),
libelle('planche.controle.consigne'),

View file

@ -422,6 +422,8 @@ describe('planche : la source imprimée, le retenu ou une proposition (Q3)', ()
assert.deepEqual(badgeDe(tiree, 1).textes.lignes, ['Tour 1 — table 7', 'Tour 2 — table 3']);
assert.deepEqual(badgeDe(retenu, 1).textes.lignes, ['Toute la soirée — table 7']);
assert.notDeepEqual(tiree, retenu);
assert.deepEqual([retenu.source, retenu.proposition], ['retenu', null]);
assert.deepEqual([tiree.source, tiree.proposition], ['proposition', 2]);
const sansChamp = avecProposition();
delete sansChamp.propositions[0].horodatage;
assert.equal(modele(sansChamp, { source: 'proposition', proposition: 2 }).horodatage, null);
@ -456,15 +458,31 @@ describe('planche : les refus avant de produire (§ 11.8, Q3)', () => {
);
});
test('une source qui se contredit : RETENU_FAUTIF, tel que le relaient les places', () => {
test('un retenu qui se contredit : RETENU_FAUTIF, tel que le relaient les places, remède la version précédente du fichier', () => {
const attendu = { code: 'RETENU_FAUTIF', details: { remede: { geste: 'ouvrirSecours', source: 'precedent' } } };
const fautif = aReserve();
fautif.retenu.placement[0].sieges[1] = [3, 3];
assert.deepEqual(refusPlanche(fautif), attendu);
assert.throws(() => modele(fautif), (erreur) => erreur instanceof ErreurCommande && erreur.code === 'RETENU_FAUTIF');
});
test('une proposition qui se contredit : PROPOSITION_FAUTIVE la nomme, remède les propositions ; le retenu sain se produit, et ne la juge pas', () => {
const proposee = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[1, 5], [3, 4]], reserve: [2] }]]] });
proposee.propositions[0].placement[1].reserve = [];
const attendu = { code: 'PROPOSITION_FAUTIVE', details: { id: 2, remede: VERS_PROPOSITIONS } };
assert.deepEqual(refusPlanche(proposee, { source: 'proposition', proposition: 2 }), attendu);
assert.throws(() => modele(fautif), (erreur) => erreur instanceof ErreurCommande && erreur.code === 'RETENU_FAUTIF');
assert.throws(
() => modele(proposee, { source: 'proposition', proposition: 2 }),
(erreur) => erreur instanceof ErreurCommande && erreur.code === 'PROPOSITION_FAUTIVE' && erreur.details.id === 2,
);
assert.equal(refusPlanche(proposee), null);
const texte = libelle(attendu.code, attendu.details);
assert.match(texte, /proposition n° 2\b/);
assert.doesNotMatch(texte, /retenu|version précédente/);
// Un retenu fautif n'empêche pas d'imprimer une proposition saine.
const retenuFautif = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[1, 5], [3, 4]], reserve: [2] }]]] });
retenuFautif.retenu.placement[0].sieges[1] = [3, 3];
assert.equal(refusPlanche(retenuFautif, { source: 'proposition', proposition: 2 }), null);
});
test('une source en dérive — une personne ajoutée depuis — : PLANCHE_DERIVE, qui la nomme avant de produire', () => {
@ -493,6 +511,10 @@ describe('planche : les refus avant de produire (§ 11.8, Q3)', () => {
fautifEtDerive.retenu.placement[0].sieges[1] = [3, 3];
fautifEtDerive.participants.push(personne(6, 'Ondine', 'Grisaille'));
assert.equal(refusPlanche(fautifEtDerive).code, 'RETENU_FAUTIF');
const fautiveEtDerive = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[1, 5], [3, 4]], reserve: [2] }]]] });
fautiveEtDerive.propositions[0].placement[1].reserve = [];
fautiveEtDerive.participants.push(personne(6, 'Ondine', 'Grisaille'));
assert.equal(refusPlanche(fautiveEtDerive, { source: 'proposition', proposition: 2 }).code, 'PROPOSITION_FAUTIVE');
const sansPlacementEtDerive = aReserve({ plan: null });
sansPlacementEtDerive.participants.push(personne(6, 'Ondine', 'Grisaille'));
assert.equal(refusPlanche(sansPlacementEtDerive).code, 'PLANCHE_SANS_PLACEMENT');
@ -632,6 +654,23 @@ describe('planche : la feuille de contrôle (§ 11.3, § 11.5, § 11.8)', () =>
}
});
test('la source se nomme : le placement retenu, ou la proposition par son numéro, avant l’horodatage du placement', () => {
const charge = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[3, 4], [1, 5]], reserve: [2] }]]] });
const options = { papier: 'A4', imposition: 'feuille', planches: 2 };
const retenu = textesControle(modele(charge), options);
const tiree = textesControle(modele(charge, { source: 'proposition', proposition: 2 }), options);
assert.ok(retenu.includes('Source : le placement retenu'), JSON.stringify(retenu));
const ligne = 'Source : la proposition n° 2, qui n\u{2019}est pas le placement retenu';
assert.ok(tiree.includes(ligne), JSON.stringify(tiree));
assert.equal(tiree.indexOf(ligne) + 1, tiree.indexOf(libelle('planche.pied.horodatage', { horodatage: H_PROPOSITION })));
assert.ok(!retenu.some((texte) => texte.includes('proposition')), JSON.stringify(retenu));
// Une source hors de son domaine lève.
const m = modele(charge);
for (const mauvaise of [{ source: undefined }, { source: 'plan' }, { source: 'proposition', proposition: null }, { source: 'retenu', proposition: 2 }]) {
assert.throws(() => textesControle({ ...m, ...mauvaise }, options), TypeError, JSON.stringify(mauvaise));
}
});
test('une réédition s’annonce, « Réédition : 3 badges », puis nomme les trois dans l’ordre de la pile', () => {
const m = modele(aReserve(), { selection: [2, 5, 4] });
const lignes = textesControle(m, { papier: 'A4', imposition: 'feuille', planches: 1 });

View file

@ -21,10 +21,20 @@
// et, pour une réédition, la sélection. Les refus s'ordonnent : ceux du
// placement (refusPlanche), puis ceux de la mise en page, nommant la
// personne du badge refusé, puis le papier à choisir.
//
// Le nom du fichier (nomPlanche) porte tout ce qui change la pile : la
// source, l'imposition, l'ordre, le placement. Une réédition, ou une
// planche dont la borne du chemin raccourcit la base (nomPlancheExigeListe),
// ne remplace jamais un fichier du dossier : son nom prend le premier rang
// libre parmi les entrées que le dossier porte, listées à chaque
// préparation et à chaque production. Un dossier qui ne se liste pas laisse
// à l'annonce le nom sans dossier, et refuse la production
// PLANCHE_NON_ECRITE avant d'écrire, plutôt que de risquer de remplacer une
// planche précédente.
import { PAPIERS } from '../geometrie/papiers.js';
import { nombrePlanches } from '../pdf/imposition.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { nomPlanche } from '../stockage/noms.js';
import { nomPlanche, nomPlancheExigeListe } from '../stockage/noms.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { IMPOSITIONS, ORDRES, modelePlanche, refusPlanche, refusProduction, textesControle } from './planche.js';
@ -118,7 +128,7 @@ export function creerPlanches({ polices, provenance, version }) {
async function etudier({ charge, papier }, demande) {
const { source, proposition, ordre, selection, imposition } = demandeLue(demande);
const rejet = refusPlanche(charge, { source, proposition });
if (rejet !== null) return { refus: rejet, modele: null, absents: [], papier, imposition, source };
if (rejet !== null) return { refus: rejet, modele: null, absents: [], papier, imposition, ordre, source };
if (typeof provenance !== 'string') throw new TypeError('provenance : le mode de construction attendu');
const modele = modelePlanche(charge, { source, proposition, ordre, selection, provenance, version });
const octets = await lesPolices();
@ -133,18 +143,63 @@ export function creerPlanches({ polices, provenance, version }) {
} else if (papier === null) {
leRefus = papierRequis();
}
return { refus: leRefus, modele, absents: analyse.absents, papier, imposition, source };
return { refus: leRefus, modele, absents: analyse.absents, papier, imposition, ordre, source };
}
// Le nom du fichier d'une étude, null sans papier.
const nomDe = (base, { modele, papier, imposition }) =>
papier === null ? null : nomPlanche(base, { papier, imposition, horodatage: modele.horodatage, reedition: modele.reedition });
// Les options de nomPlanche pour une étude, sous le dossier donné.
const optionsDe = ({ modele, papier, imposition, ordre }, dossier) => ({
papier,
imposition,
ordre,
horodatage: modele.horodatage,
reedition: modele.reedition,
proposition: modele.proposition,
dossier,
});
// Les octets de la planche d'une demande, et son nom ; le refus de
// l'étude lève, ErreurCommande, avant que rien ne se produise.
async function octetsDe(lecture, demande) {
// Le nom du fichier d'une étude, sous le dossier donné (nomPlanche) ;
// null sans papier.
const nomDe = (base, etude, dossier) => (etude.papier === null ? null : nomPlanche(base, optionsDe(etude, dossier)));
// Le dossier où la planche d'une étude se dépose, tel que nomPlanche le
// lit : sa racine, son séparateur et, quand le nom se choisit parmi elles
// (nomPlancheExigeListe), les noms qu'il porte ; null sous web, où la
// planche se télécharge. Un dossier qui ne se liste pas lève
// l'ErreurStockage de la liste.
async function dossierDe({ fs, racine, base }, etude) {
if (fs.nature === 'web') return null;
const { separateur } = await fs.emplacements();
const sansListe = { racine: racine.chemin, separateur, existants: [] };
if (!nomPlancheExigeListe(base, optionsDe(etude, sansListe))) return sansListe;
const existants = (await fs.lister(racine, '')).map(({ nom }) => nom);
return { ...sansListe, existants };
}
// Le refus d'une planche qui ne se dépose pas : le nom, le dossier, la
// cause, remède « Enregistrer sous… ». Le support de l'événement n'en est
// pas rompu.
const nonEcrite = ({ chemin, dossier, cause }) =>
refus('PLANCHE_NON_ECRITE', { chemin, dossier, cause: cause ?? null }, { geste: 'enregistrerSous' });
// Les octets de la planche d'une demande, et son nom : sous le dossier de
// la lecture quand avecDossier est vrai, sans dossier sinon. Le refus de
// l'étude lève, ErreurCommande, avant que rien ne se produise. Le dossier
// se lit avant de produire : une liste refusée lève PLANCHE_NON_ECRITE
// sans rien produire ni écrire.
async function octetsDe(lecture, demande, { avecDossier }) {
const etude = await etudier(lecture, demande);
if (etude.refus !== null) throw new ErreurCommande(etude.refus.code, etude.refus.details);
let dossier = null;
if (avecDossier) {
try {
dossier = await dossierDe(lecture, etude);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
const cause = erreur.details?.cause ?? erreur.code;
throw nonEcrite({ chemin: nomDe(lecture.base, etude, null), dossier: lecture.racine.chemin, cause });
}
}
const nom = nomDe(lecture.base, etude, dossier);
const { modele, papier, imposition, absents } = etude;
const planches = nombrePlanches(modele.badges.length, papier);
const controle = textesControle(modele, { papier, imposition, planches, absents });
@ -157,7 +212,7 @@ export function creerPlanches({ polices, provenance, version }) {
controle,
filigrane: libelle('planche.brouillon'),
});
return { octets, nom: nomDe(lecture.base, etude) };
return { octets, nom };
}
return {
@ -166,7 +221,9 @@ export function creerPlanches({ polices, provenance, version }) {
* décision 12). Un refus du placement rend l'annonce sans modèle :
* comptes, planches et nom null, ni homonymes ni absents, le brouillon
* de la source. Sans papier, refus PAPIER_REQUIS et nom null, les
* planches des deux papiers données. Rien ne s'écrit.
* planches des deux papiers données. Le nom est celui que la production
* donnerait, rang compris ; sans dossier, donc sans rang, quand le dossier
* ne se liste pas. Rien ne s'écrit.
*
* @throws {TypeError} une demande hors de son domaine, une séance sans
* polices ou sans provenance
@ -189,6 +246,14 @@ export function creerPlanches({ polices, provenance, version }) {
};
}
const { modele } = etude;
let dossier = null;
if (papier !== null) {
try {
dossier = await dossierDe(lecture, etude);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
}
}
return {
refus: etude.refus,
papier,
@ -197,7 +262,7 @@ export function creerPlanches({ polices, provenance, version }) {
homonymes: modele.homonymes,
absents: etude.absents,
brouillon: modele.brouillon,
nom: nomDe(lecture.base, etude),
nom: nomDe(lecture.base, etude, dossier),
};
},
@ -206,14 +271,16 @@ export function creerPlanches({ polices, provenance, version }) {
* d'événement, par une écriture atomique, sous electron et sur le
* système d'épreuve, et rend son chemin en entier ; sous web, par
* enregistrerSous, le téléchargement, et chemin null. Le refus de
* l'annonce lève, sans rien écrire. Une écriture refusée par le support
* lève PLANCHE_NON_ECRITE {chemin, dossier, cause}, remède
* l'annonce lève, sans rien écrire. Une écriture refusée par le support,
* ou un dossier qui ne se liste pas quand le nom se choisit parmi ses
* entrées, lève
* PLANCHE_NON_ECRITE {chemin, dossier, cause}, remède
* « Enregistrer sous… » : le support de l'événement n'en est pas rompu.
*
* @throws {ErreurCommande} le refus de l'annonce ; PLANCHE_NON_ECRITE
*/
async produire(lecture, demande) {
const { octets, nom } = await octetsDe(lecture, demande);
const { octets, nom } = await octetsDe(lecture, demande, { avecDossier: true });
const { fs, racine } = lecture;
if (fs.nature === 'web') {
await fs.enregistrerSous(nom, octets);
@ -223,8 +290,7 @@ export function creerPlanches({ polices, provenance, version }) {
await fs.ecrireOctetsAtomique(racine, nom, octets);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage && erreur.code === 'ECRITURE')) throw erreur;
const { chemin, dossier, cause } = erreur.details;
throw refus('PLANCHE_NON_ECRITE', { chemin, dossier, cause }, { geste: 'enregistrerSous' });
throw nonEcrite(erreur.details);
}
const { separateur } = await fs.emplacements();
const tete = racine.chemin.endsWith(separateur) ? racine.chemin : racine.chemin + separateur;
@ -233,13 +299,14 @@ export function creerPlanches({ polices, provenance, version }) {
/**
* Produit la planche et la confie au dialogue d'enregistrement de la
* plateforme, sous le nom de la planche : le remède d'une écriture
* plateforme, sous le nom de la planche sans rang ni coupe, que
* l'opérateur garde ou change avec le dossier : le remède d'une écriture
* refusée. Rend le chemin choisi, null quand l'opérateur annule.
*
* @throws {ErreurCommande} le refus de l'annonce
*/
async enregistrerSous(lecture, demande) {
const { octets, nom } = await octetsDe(lecture, demande);
const { octets, nom } = await octetsDe(lecture, demande, { avecDossier: false });
return lecture.fs.enregistrerSous(nom, octets);
},
};

View file

@ -11,17 +11,17 @@
//
// La série surveillée borne le coût (§ 14.4, contrainte 31) : douze
// productions de PDF au plus dans ce fichier, sur une soirée de quatre
// personnes — trois pour les trois modes, trois pour la stabilité des
// personnes — trois pour les trois modes, deux pour la stabilité des
// octets, une réédition, deux refus d'écriture et l'enregistrement sous,
// une sous web, une proposition. L'imposition et l'ordre se lisent dans la
// place de chaque nom, et le papier, l'imposition et le nombre de planches
// dans la feuille de contrôle : la première production, sous la demande
// par défaut, en A4 — l'ordre alphabétique ; la proposition, sous la
// demande par défaut, en Lettre — feuille par feuille sur deux planches ;
// celle sous web en Lettre, en pile, par table. La réédition porte un
// caractère sans glyphe, que sa feuille de contrôle nomme. Le témoin de 257
// ne s'analyse que pour son annonce. Les noms sont inventés ; la version
// vient du module engendré.
// une sous web, deux propositions de la même génération. L'imposition et
// l'ordre se lisent dans la place de chaque nom, et le papier, l'imposition
// et le nombre de planches dans la feuille de contrôle : la première
// production, sous la demande par défaut, en A4 — l'ordre alphabétique ; la
// proposition, sous la demande par défaut, en Lettre — feuille par feuille
// sur deux planches ; celle sous web en Lettre, en pile, par table. La
// réédition porte un caractère sans glyphe, que sa feuille de contrôle
// nomme. Le témoin de 257 ne s'analyse que pour son annonce. Les noms sont
// inventés ; la version vient du module engendré.
import assert from 'node:assert/strict';
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
import { H_PROPOSITION, H_RETENU, personne, soiree, table } from '../../test/fixtures/badges/cas.js';
@ -32,9 +32,10 @@ import { lirePolices } from '../../test/polices.js';
import { PAPIERS } from '../geometrie/papiers.js';
import { cellules } from '../pdf/imposition.js';
import { serialiser } from '../stockage/canonique.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { FORMAT } from '../stockage/document.js';
import { SUFFIXES, nomPlanche } from '../stockage/noms.js';
import { SUFFIXES, deriverBase, nomPlanche } from '../stockage/noms.js';
import { VERSION } from '../version.genere.js';
import { libelle } from './libelles.js';
import { creerSeance } from './seance.js';
@ -109,8 +110,8 @@ function chargeLucioles({ personnes = null, ...champs } = {}) {
}
// Le nom de la planche de la soirée d'essai.
const nomDe = ({ papier = 'A4', imposition = 'feuille', horodatage = H_RETENU, reedition = null } = {}) =>
nomPlanche(BASE, { papier, imposition, horodatage, reedition });
const nomDe = ({ papier = 'A4', imposition = 'feuille', horodatage = H_RETENU, reedition = null, proposition = null, ordre } = {}) =>
nomPlanche(BASE, { papier, imposition, horodatage, reedition, proposition, ordre });
// Pose l'événement de la charge sur le disque, par le dépôt ; format :
// celui qu'annonce l'en-tête de l'état, FORMAT par défaut.
@ -131,7 +132,7 @@ async function deposer(fs, charge, { format = FORMAT } = {}) {
// Une séance sur l'événement de la charge, ouvert en lecture, le papier
// choisi quand il est donné ; la trace part ensuite, vide.
async function monter(charge = chargeLucioles(), { papier = 'A4', fs = creerFichiersSimules({ documents: DOCUMENTS }), format } = {}) {
async function monter(charge = chargeLucioles(), { papier = 'A4', fs = creerFichiersSimules({ documents: DOCUMENTS }), format, base = BASE } = {}) {
await deposer(fs, charge, { format });
let tirage = 0;
const seance = creerSeance({
@ -149,7 +150,7 @@ async function monter(charge = chargeLucioles(), { papier = 'A4', fs = creerFich
provenance: 'production',
});
if (papier !== null) await seance.choisirPapier(papier);
await seance.ouvrir(BASE);
await seance.ouvrir(base);
const trace = tracer(fs);
return { fs, seance, trace };
}
@ -315,7 +316,9 @@ describe('planche dans la séance : l’annonce (§ 11.2, § 11.5, § 11.8, déc
test('une proposition s’annonce BROUILLON, sous son propre horodatage ; sans placement, PLANCHE_SANS_PLACEMENT et rien du modèle', async () => {
const { seance } = await monter();
const proposee = await seance.preparerPlanche({ source: 'proposition', proposition: 2 });
assert.deepEqual([proposee.refus, proposee.brouillon, proposee.nom], [null, true, nomDe({ horodatage: H_PROPOSITION })]);
assert.deepEqual([proposee.refus, proposee.brouillon, proposee.nom], [null, true, nomDe({ horodatage: H_PROPOSITION, proposition: 2 })]);
assert.notEqual((await seance.preparerPlanche({ source: 'proposition', proposition: 1 })).nom, proposee.nom);
assert.equal((await seance.preparerPlanche({ ordre: 'table' })).nom, nomDe({ ordre: 'table' }));
const sans = await monter(chargeLucioles({ plan: null, etat: 'propose' }));
const annonce = await sans.seance.preparerPlanche();
@ -360,11 +363,12 @@ describe('planche dans la séance : l’annonce (§ 11.2, § 11.5, § 11.8, déc
});
describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', () => {
test('deux productions du même placement : mêmes octets, même nom ; un geste lancé pendant la seconde attend son tour ; la retouche change le nom ; défaire rend le premier', async () => {
test('deux productions du même placement : mêmes octets, même nom ; un geste lancé pendant la seconde attend son tour ; la retouche change le nom ; défaire rend la charge, donc le premier', async () => {
const { fs, seance, trace } = await monter();
const premier = await seance.produirePlanche();
const octetsPremiers = fs.octets('documents', premier.nom);
await seance.passerEnEcriture();
const chargePremiere = structuredClone(seance.charge);
trace.length = 0;
const production = seance.produirePlanche();
@ -382,19 +386,30 @@ describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', ()
assert.notEqual(horodatage, H_RETENU);
assert.equal(retouche.nom, nomDe({ horodatage }));
// Défaire rend la charge entière, horodatage compris : la production,
// qui ne lit qu'elle et la demande, rend les octets que les deux
// premières ont rendus à l'identique, sous le même nom.
await seance.defaire();
const troisieme = await seance.produirePlanche();
assert.deepEqual(troisieme, premier);
assert.deepEqual(fs.octets('documents', troisieme.nom), octetsPremiers);
assert.deepEqual(seance.charge, chargePremiere);
assert.equal((await seance.preparerPlanche()).nom, premier.nom);
});
test('une réédition de trois badges : un autre nom, « -3 », et la feuille de contrôle dit « Réédition : 3 badges » et nomme le caractère sans glyphe d’« Ondine 李 »', async () => {
const charge = chargeLucioles();
charge.participants[2].nom = '李';
const { fs, seance } = await monter(charge);
const { fs, seance, trace } = await monter(charge);
// Une réédition de trois autres personnes, déjà posée sous le même nom :
// la nouvelle prend le rang libre, et la première reste.
const pris = nomDe({ reedition: { ids: [3, 1, 4] } });
const anciens = new TextEncoder().encode('%PDF-réédition précédente');
fs.deposerOctets('documents', pris, anciens);
const annonce = await seance.preparerPlanche({ selection: [3, 1, 4] });
const rendu = await seance.produirePlanche({ selection: [3, 1, 4] });
assert.equal(rendu.nom, nomDe({ reedition: { ids: [3, 1, 4] } }));
assert.equal(rendu.nom, pris.replace(/\.pdf$/, '_2.pdf'));
assert.equal(annonce.nom, rendu.nom);
assert.match(rendu.nom, /\.badges-3_A4_feuille_/);
assert.deepEqual(fs.octets('documents', pris), anciens);
assert.deepEqual(trace, [['ecrireOctetsAtomique', rendu.nom]]);
const pdf = await lirePdf(fs.octets('documents', rendu.nom));
assert.ok(textesDe(pdf.pages[0]).includes(libelle('planche.controle.reedition', { n: 3 })), textesDe(pdf.pages[0]).join(' | '));
assert.ok(textesDe(pdf.pages[0]).includes('Réédition : 3 badges'));
@ -403,11 +418,33 @@ describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', ()
assert.equal(pdf.pages.length, 2);
});
test('une proposition produite porte le mot du filigrane au trait, sa feuille de contrôle aussi ; en Lettre sous la demande par défaut, feuille par feuille, dans l’ordre alphabétique', async () => {
const { fs, seance } = await monter(chargeLucioles(), { papier: 'Lettre' });
test('deux propositions de la même génération, au même horodatage : deux fichiers ; la seconde porte le mot du filigrane au trait, sa feuille de contrôle aussi et la nomme ; en Lettre sous la demande par défaut, feuille par feuille, dans l’ordre alphabétique', async () => {
const charge = chargeLucioles();
assert.equal(charge.propositions[0].horodatage, charge.propositions[1].horodatage);
const { fs, seance, trace } = await monter(charge, { papier: 'Lettre' });
const premiere = await seance.produirePlanche({ source: 'proposition', proposition: 1 });
const octetsPremiers = fs.octets('documents', premiere.nom);
const rendu = await seance.produirePlanche({ source: 'proposition', proposition: 2 });
assert.equal(rendu.nom, nomDe({ papier: 'Lettre', horodatage: H_PROPOSITION }));
assert.deepEqual(
[premiere.nom, rendu.nom],
[1, 2].map((proposition) => nomDe({ papier: 'Lettre', horodatage: H_PROPOSITION, proposition })),
);
assert.notEqual(premiere.nom, rendu.nom);
assert.deepEqual(fs.octets('documents', premiere.nom), octetsPremiers);
assert.notDeepEqual(fs.octets('documents', rendu.nom), octetsPremiers);
const pdfs = (await fs.lister(DOC, '')).map(({ nom }) => nom).filter((nom) => nom.endsWith('.pdf'));
assert.deepEqual(pdfs, [premiere.nom, rendu.nom]);
assert.deepEqual(trace, [
['ecrireOctetsAtomique', premiere.nom],
['ecrireOctetsAtomique', rendu.nom],
]);
// La seconde : le mot du filigrane au trait, sur ses planches et sa
// feuille de contrôle, qui la nomme ; en Lettre sous la demande par
// défaut, feuille par feuille, dans l'ordre alphabétique.
const pdf = await lirePdf(fs.octets('documents', rendu.nom));
const source = libelle('planche.controle.source', { source: 'proposition', proposition: 2 });
assert.ok(textesDe(pdf.pages[0]).includes(source), textesDe(pdf.pages[0]).join(' | '));
assert.ok(pdf.pages.every((page) => textesDe(page).includes(FILIGRANE)));
assert.equal(auTrait(pdf), true);
// Deux badges par planche : feuille par feuille, la planche p porte les
@ -425,6 +462,60 @@ describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', ()
});
});
describe('planche dans la séance : un nom par pile (§ 11.6)', () => {
test('une réédition dont le dossier ne se liste pas : l’annonce garde le nom sans rang, produire se refuse PLANCHE_NON_ECRITE avant d’écrire, plutôt que de risquer un remplacement', async () => {
const { fs, seance, trace } = await monter();
const lister = fs.lister;
fs.lister = async () => {
throw new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EACCES' });
};
const nom = nomDe({ reedition: { ids: [2] } });
assert.equal((await seance.preparerPlanche({ selection: [2] })).nom, nom);
await refusee(seance.produirePlanche({ selection: [2] }), 'PLANCHE_NON_ECRITE', {
chemin: nom,
dossier: DOCUMENTS,
cause: 'EACCES',
remede: { geste: 'enregistrerSous' },
});
assert.deepEqual(trace, []);
// Une planche entière ne liste pas le dossier : elle se nomme sans lui.
assert.equal((await seance.preparerPlanche()).nom, nomDe());
fs.lister = lister;
});
test('une planche entière dont la base se raccourcit liste le dossier : elle prend le premier rang libre plutôt que de remplacer un fichier ; un dossier qui ne se liste pas la refuse avant d’écrire', async () => {
const nom = `Souper des Lucioles du printemps, \u{E9}dition sp\u{E9}ciale ${'et des Merles '.repeat(12)}`;
const fs = creerFichiersSimules({ documents: DOCUMENTS });
const { separateur } = await fs.emplacements();
const base = deriverBase(nom, { racine: DOCUMENTS, separateur, existantes: [] });
const { seance, trace } = await monter(chargeLucioles({ nom }), { papier: 'Lettre', fs, base });
const demande = { source: 'proposition', proposition: 2, ordre: 'table' };
const options = { papier: 'Lettre', imposition: 'feuille', horodatage: H_PROPOSITION, reedition: null, proposition: 2, ordre: 'table' };
const raccourci = nomPlanche(base, { ...options, dossier: { racine: DOCUMENTS, separateur, existants: [] } });
assert.notEqual(raccourci, nomPlanche(base, options), 'la base se raccourcit');
assert.equal((await seance.preparerPlanche(demande)).nom, raccourci);
// Le fichier d'un autre événement porte déjà ce nom : la planche prend le rang suivant.
fs.deposer('documents', raccourci, 'planche d’un autre événement');
const auRang2 = nomPlanche(base, { ...options, dossier: { racine: DOCUMENTS, separateur, existants: [raccourci] } });
assert.ok(auRang2.endsWith('_2.pdf'), auRang2);
assert.equal((await seance.preparerPlanche(demande)).nom, auRang2);
const lister = fs.lister;
fs.lister = async () => {
throw new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EACCES' });
};
assert.equal((await seance.preparerPlanche(demande)).nom, nomPlanche(base, options));
await refusee(seance.produirePlanche(demande), 'PLANCHE_NON_ECRITE', {
chemin: nomPlanche(base, options),
dossier: DOCUMENTS,
cause: 'EACCES',
remede: { geste: 'enregistrerSous' },
});
fs.lister = lister;
assert.deepEqual(trace, []);
assert.equal(fs.contenu('documents', raccourci), 'planche d’un autre événement');
});
});
describe('planche dans la séance : l’écriture refusée, enregistrer sous, le web (§ 8.6, décision 13)', () => {
test('un renommage refusé : PLANCHE_NON_ECRITE nomme le fichier, le dossier et la cause, offre « Enregistrer sous… », et le support de l’événement reste intact', async () => {
const { fs, seance, trace } = await monter();
@ -475,10 +566,10 @@ describe('planche dans la séance : l’écriture refusée, enregistrer sous, le
recus.push([nom, octets]);
return nom;
};
const attendu = nomDe({ papier: 'Lettre', imposition: 'pile' });
const attendu = nomDe({ papier: 'Lettre', imposition: 'pile', ordre: 'table' });
const rendu = await seance.produirePlanche({ imposition: 'pile', ordre: 'table' });
assert.deepEqual(rendu, { chemin: null, nom: attendu });
assert.match(attendu, /_Lettre_pile_/);
assert.match(attendu, /_Lettre_pile_table_/);
assert.deepEqual(trace, []);
assert.equal(recus.length, 1);
assert.equal(recus[0][0], attendu);

View file

@ -73,12 +73,14 @@ const retenue = (champs = {}) =>
// Les opérations de la planche d'une séance, sur la police embarquée lue
// du disque, la provenance d'épreuve et la version engendrée.
const planches = creerPlanches({ polices: async () => lirePolices(), provenance: 'test', version: VERSION.affichee });
const fsInutile = Object.freeze({ nature: 'epreuve' });
// Un dossier de travail vide : le nom de la planche ne lit que son
// séparateur et, pour une réédition, ses entrées.
const fsVide = Object.freeze({ nature: 'epreuve', emplacements: async () => ({ separateur: '/' }), lister: async () => [] });
// L'annonce que la séance rendrait sur cette charge, ce papier et cette
// demande : la lecture que la séance passe, sans système de fichiers lu.
// demande : la lecture que la séance passe, sur un dossier vide.
const annoncer = (charge, papier, demande = {}) =>
planches.preparer({ charge, base: 'lucioles', fs: fsInutile, racine: { id: 'documents', chemin: '/d' }, papier }, demande);
planches.preparer({ charge, base: 'lucioles', fs: fsVide, racine: { id: 'documents', chemin: '/d' }, papier }, demande);
describe('les sources offertes (Q3)', () => {
test('le retenu d’abord, puis la proposition demandée au plan quand elle existe ; rien sans l’un ni l’autre', () => {
@ -206,7 +208,7 @@ describe('les phrases de l’annonce (§ 11.2, § 11.5, § 11.8)', () => {
assert.equal(produireBloque(null), false);
assert.equal(produireBloque({ ...vide, refus: null }), false);
assert.equal(produireBloque({ ...vide, refus: { code: 'PAPIER_REQUIS', details: {} } }), false);
for (const code of ['PLANCHE_SANS_PLACEMENT', 'RETENU_FAUTIF', 'PLANCHE_DERIVE', 'PLANCHE_TROP_DE_TOURS', 'PLANCHE_NOM_TROP_LONG']) {
for (const code of ['PLANCHE_SANS_PLACEMENT', 'RETENU_FAUTIF', 'PROPOSITION_FAUTIVE', 'PLANCHE_DERIVE', 'PLANCHE_TROP_DE_TOURS', 'PLANCHE_NOM_TROP_LONG']) {
assert.equal(produireBloque({ ...vide, refus: { code, details: {} } }), true, code);
}
});

View file

@ -54,6 +54,8 @@ function modele(n, { brouillon = false, reedition = false } = {}) {
});
return {
badges,
source: 'retenu',
proposition: null,
evenement: EVENEMENT,
horodatage: HORODATAGE,
brouillon,

View file

@ -11,8 +11,9 @@
//
// La série surveillée borne le coût (§ 14.4) : trois productions sur de
// petits modèles écrits à la main, que toutes les épreuves du fichier
// lisent — A4, cinq badges, feuille par feuille, brouillon, horodatage
// connu, produite deux fois ; Lettre, trois badges, en pile, retenu,
// lisent — A4, cinq badges, feuille par feuille, brouillon tiré de la
// proposition n° 7, horodatage connu, produite deux fois ; Lettre, trois
// badges, en pile, retenu,
// horodatage inconnu, sous un nom d'événement plus large que la page, dont
// la ligne de contrôle se replie aux espaces, reporte entier un mot qui
// tient seul, puis coupe un mot qui ne tient pas seul. Les lignes de
@ -72,9 +73,11 @@ function badge(participant, nom, { appartenance = null, tours = 2, horodatage =
}
/** Un modèle de planche écrit à la main. */
function modele(badges, { horodatage = HORODATAGE, brouillon = false, exclus = 0, evenement = EVENEMENT } = {}) {
function modele(badges, { horodatage = HORODATAGE, brouillon = false, exclus = 0, evenement = EVENEMENT, proposition = null } = {}) {
return {
badges,
source: proposition === null ? 'retenu' : 'proposition',
proposition,
evenement,
horodatage,
brouillon,
@ -88,7 +91,7 @@ function modele(badges, { horodatage = HORODATAGE, brouillon = false, exclus = 0
const NOMS_A4 = ['Iris Ombrelle', 'Ôjgq Æ Grisaille', 'Théo Pervenche', 'Ondine Lacasse', 'Merle Bleuet'];
const MODELE_A4 = modele(
NOMS_A4.map((nom, i) => badge(i + 1, nom, { appartenance: i % 2 === 0 ? 'Club des Merles' : null })),
{ brouillon: true, exclus: 1 },
{ brouillon: true, exclus: 1, proposition: 7 },
);
const NOMS_LETTRE = ['Aube Sarcelle', 'Côme Pervenche', 'Ysée Lacasse'];
const MODELE_LETTRE = modele(
@ -448,7 +451,7 @@ describe('produirePlanche : la feuille de contrôle, page 1 (§ 11.3)', () => {
}
});
test("les lignes de contrôle se lisent dans la couche de texte, dans l'ordre et entières : comptes, horodatage, version, papier", () => {
test("les lignes de contrôle se lisent dans la couche de texte, dans l'ordre et entières : comptes, source, horodatage, version, papier", () => {
for (const { papier, imposition, lu, modele: m } of PRODUCTIONS) {
const lignes = textesControle(m, { papier, imposition, planches: nombrePlanches(m.badges.length, papier) });
recomposer(fragmentsDeControle(lu.pages[0]), lignes);
@ -457,6 +460,9 @@ describe('produirePlanche : la feuille de contrôle, page 1 (§ 11.3)', () => {
assert.ok(lusA4.includes(libelle('planche.controle.comptes', MODELE_A4.comptes)));
assert.ok(lusA4.includes(libelle('planche.pied.horodatage', { horodatage: HORODATAGE })));
assert.ok(LETTRE.pages[0].textes.some(({ texte }) => texte === libelle('planche.pied.horodatageInconnu')));
// La source se nomme : la proposition imprimée par son numéro, ou le retenu.
assert.ok(lusA4.includes('Source : la proposition n° 7, qui n\u{2019}est pas le placement retenu'), lusA4.join(' | '));
assert.ok(LETTRE.pages[0].textes.some(({ texte }) => texte === 'Source : le placement retenu'));
});
test("une ligne plus large que la page se replie dans la largeur utile : aux espaces, puis dans le mot qui ne tient pas seul ; chaque morceau aussi plein qu'il se peut", () => {

View file

@ -18,8 +18,10 @@
// tiendrait que pour l'état laisserait déborder le journal, le fichier
// précédent et leurs fichiers d'écriture. Le nom de la planche, posé à la
// racine du dossier de travail, tient sous la même borne : son plus long
// suffixe, prolongé de SUFFIXE_ECRITURE, ne dépasse pas la partie fixe de
// longueurRelativeMax.
// suffixe sans marque, prolongé de SUFFIXE_ECRITURE, ne dépasse pas la partie
// fixe de longueurRelativeMax, et un nom marqué raccourcit sa base dans le
// dossier qui le reçoit, suivie d'un point qui le distingue de tout nom
// entier.
import { PAPIERS } from '../geometrie/papiers.js';
import { ErreurStockage } from './erreurs.js';
@ -305,25 +307,126 @@ export function dossierCorbeille(horodatage, existants) {
// écrit.
const IMPOSITIONS = Object.freeze(['feuille', 'pile']);
// Les ordres de la pile (§ 11.6) : l'ordre par table se marque dans le nom,
// l'ordre alphabétique, celui par défaut, ne s'y écrit pas.
const ORDRES = Object.freeze(['alphabetique', 'table']);
// Une réédition compte au plus REEDITION_MAX badges, quatre chiffres : le
// plus long suffixe de la planche, « .badges-9999_<papier>_feuille_<date>.pdf »,
// en tient compte sous la borne du chemin.
// plus long suffixe sans marque de la planche,
// « .badges-9999_<papier>_feuille_<date>.pdf », en tient compte sous la borne
// du chemin.
const REEDITION_MAX = 9999;
// Ce qui tient lieu de date et d'heure quand le placement n'en porte pas.
const HORODATAGE_INCONNU = 'horodatage-inconnu';
// Ce qui suit une base raccourcie dans le nom de la planche : un point. Une
// base que rend deriverBase ne finit jamais par un point, si bien qu'un nom
// raccourci ne se confond avec aucun nom entier : la planche entière d'une
// base qui tient ne remplace jamais la planche raccourcie d'une autre.
const MARQUE_COUPE = '.';
// Le dossier de travail où la planche se dépose, contrôlé : null, ou sa
// racine, son séparateur et les noms qu'il porte déjà.
function dossierLu(dossier) {
if (dossier === null) return null;
const forme =
typeof dossier === 'object' &&
typeof dossier.racine === 'string' &&
(dossier.separateur === '\\' || dossier.separateur === '/') &&
typeof dossier.existants?.[Symbol.iterator] === 'function';
if (!forme) throw new TypeError('nomPlanche : dossier {racine, separateur, existants} ou null attendu');
return dossier;
}
// Les options de nomPlanche contrôlées, et ce qu'il en tire : le dossier lu,
// le nom de rang donné, et si le nom se choisit parmi les entrées du dossier
// — une réédition, ou une base que la borne raccourcit dès le premier rang.
// Lève comme nomPlanche.
function composer(
base,
{ papier, imposition, horodatage, reedition = null, proposition = null, ordre = 'alphabetique', dossier = null } = {},
) {
if (typeof base !== 'string' || base === '') throw new TypeError(`nomPlanche : base attendue, reçu ${JSON.stringify(base)}`);
if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) {
throw new TypeError(`nomPlanche : papier inconnu ${JSON.stringify(papier)}`);
}
if (!IMPOSITIONS.includes(imposition)) throw new TypeError(`nomPlanche : imposition inconnue ${JSON.stringify(imposition)}`);
if (proposition !== null && !(Number.isSafeInteger(proposition) && proposition >= 1)) {
throw new TypeError(`nomPlanche : identifiant de proposition attendu, reçu ${JSON.stringify(proposition)}`);
}
if (!ORDRES.includes(ordre)) throw new TypeError(`nomPlanche : ordre inconnu ${JSON.stringify(ordre)}`);
const leDossier = dossierLu(dossier);
const date = horodatage === null ? HORODATAGE_INCONNU : jourEtHeure(horodatage);
let compte = '';
if (reedition !== null) {
if (!Array.isArray(reedition?.ids)) throw new TypeError('nomPlanche : réédition sans tableau ids');
const k = reedition.ids.length;
if (k < 1 || k > REEDITION_MAX) throw new RangeError(`nomPlanche : réédition de ${k} badges, de 1 à ${REEDITION_MAX} admis`);
compte = `-${k}`;
}
const source = proposition === null ? '' : `-p${proposition}`;
const marqueOrdre = ordre === 'table' ? '_table' : '';
const suffixe = (rang) =>
`.badges${source}${compte}_${papier}_${imposition}${marqueOrdre}_${date}${rang === 1 ? '' : `_${rang}`}.pdf`;
if (leDossier === null) return { leDossier, nomDeRang: (rang) => base + suffixe(rang), exigeListe: false };
// Le nom de rang donné : la base entière quand le chemin complet, en
// écriture atomique, tient sous la borne ; sinon la plus longue tête de la
// base que tenir rend, suivie de MARQUE_COUPE ; la base entière quand même
// un caractère n'y tient pas.
const { racine, separateur } = leDossier;
const unitesRacine = racine.endsWith(separateur) ? racine.length - 1 : racine.length;
const nomDeRang = (rang) => {
const fin = suffixe(rang);
const libres = LONGUEUR_CHEMIN_MAX - (unitesRacine + 1) - SUFFIXE_ECRITURE.length - fin.length;
if (base.length <= libres) return base + fin;
const tete = tenir(base, libres - MARQUE_COUPE.length);
return tete === null ? base + fin : tete + MARQUE_COUPE + fin;
};
return { leDossier, nomDeRang, exigeListe: reedition !== null || nomDeRang(1) !== base + suffixe(1) };
}
/**
* Nom du fichier de la planche de badges (§ 11.6), posé à la racine du
* dossier de travail : <base>.badges[-<k>]_<papier>_<imposition>_<date>.pdf,
* où k est le nombre de badges d'une réédition, papier une clé de PAPIERS,
* imposition feuille ou pile, et date le jour et l'heure de l'horodatage du
* placement, AAAA-MM-JJ_HH-MM-SS, décalage retiré — « horodatage-inconnu »
* quand il manque. Le nom ne porte aucun deux-points. Même base, même
* papier, même imposition, même placement : même nom. Le plus long suffixe,
* 51 unités, prolongé de SUFFIXE_ECRITURE, tient sous la partie fixe de
* longueurRelativeMax : la base d'un événement, que deriverBase borne, laisse
* donc place à sa planche.
* dossier de travail :
* <base>.badges[-p<id>][-<k>]_<papier>_<imposition>[_table]_<date>[_<n>].pdf,
* où id est l'identifiant de la proposition imprimée — rien pour le
* retenu —, k le nombre de badges d'une réédition, papier une clé de
* PAPIERS, imposition feuille ou pile, « _table » l'ordre par table — rien
* pour l'ordre alphabétique —, date le jour et l'heure de l'horodatage du
* placement, AAAA-MM-JJ_HH-MM-SS, décalage retiré, « horodatage-inconnu »
* quand il manque, et n le rang d'un nom déjà pris que le dossier départage.
* Le nom ne porte aucun deux-points.
*
* Le nom est injectif sur ce qui change la pile : deux propositions d'une
* même génération, qui partagent leur horodatage, ou les deux ordres d'un
* même placement donnent deux noms. Même base, même source, même papier,
* même imposition, même ordre, même placement : même nom, et la planche
* entière d'un placement remplace la sienne quand sa base tient entière.
* Un nom qui se choisit parmi les entrées du dossier — celui d'une
* réédition, ou celui dont la base se raccourcit (nomPlancheExigeListe) — ne
* remplace jamais un fichier : quand le dossier porte déjà ce nom, comparé
* par la majuscule de sa NFC comme Windows compare les noms, il prend le
* premier rang libre, « _2 », « _3 »…, comme le dossier daté de la
* corbeille. Deux bases qui ne diffèrent qu'au-delà de la coupe, celle d'un
* événement et celle de sa copie, se raccourcissent en une même tête : la
* seconde planche prend un rang plutôt que de remplacer la première.
*
* La borne du chemin (§ 8.6) : le plus long suffixe sans marque, 51 unités,
* prolongé de SUFFIXE_ECRITURE, tient sous la partie fixe de
* longueurRelativeMax, et la base d'un événement, que deriverBase borne,
* laisse place à sa planche. Les marques et le rang l'allongent au-delà :
* quand le dossier est donné, la base du nom se raccourcit, sans couper une
* paire de substitution, espaces et points finaux retirés, sans laisser un
* nom réservé, et prend un point à sa suite, jusqu'à ce que racine +
* séparateur + nom + SUFFIXE_ECRITURE tienne en LONGUEUR_CHEMIN_MAX ; le
* suffixe reste entier. Aucune base que rend deriverBase ne finit par un
* point : un nom raccourci ne se confond avec aucun nom entier.
* Quand même un caractère de base ne tient pas, le nom garde sa base
* entière : l'écriture refusée par le support se dit, et « Enregistrer
* sous… » la dépose ailleurs. Sans dossier — sous web, où la planche se
* télécharge, et pour le dialogue d'enregistrement —, ni rang ni coupe.
*
* @param {string} base celle de l'événement, que rend deriverBase
* @param {Object} options
@ -332,25 +435,44 @@ const HORODATAGE_INCONNU = 'horodatage-inconnu';
* @param {string|null} options.horodatage AAAA-MM-JJTHH:MM:SS±HH:MM, ou null
* @param {null|{ids: number[]}} [options.reedition] les badges réédités ;
* null ou absent pour une planche entière
* @param {number|null} [options.proposition] l'identifiant de la
* proposition imprimée ; null ou absent pour le retenu
* @param {'alphabetique'|'table'} [options.ordre] 'alphabetique' par défaut
* @param {null|{racine: string, separateur: '\\'|'/', existants: Iterable<string>}} [options.dossier]
* le dossier de travail : sa racine, son séparateur, et les noms des
* entrées qu'il porte déjà à sa racine
* @returns {string}
* @throws {TypeError} une base vide ou autre chose qu'une chaîne, un papier
* hors de PAPIERS, une imposition hors de feuille et pile, un
* horodatage d'une autre forme, une réédition sans tableau ids
* horodatage d'une autre forme, une réédition sans tableau ids, une
* proposition qui n'est pas un entier ≥ 1, un ordre inconnu, un
* dossier d'une autre forme
* @throws {RangeError} une réédition de 0 badge ou de plus de 9 999
*/
export function nomPlanche(base, { papier, imposition, horodatage, reedition = null } = {}) {
if (typeof base !== 'string' || base === '') throw new TypeError(`nomPlanche : base attendue, reçu ${JSON.stringify(base)}`);
if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) {
throw new TypeError(`nomPlanche : papier inconnu ${JSON.stringify(papier)}`);
export function nomPlanche(base, options) {
const { leDossier, nomDeRang, exigeListe } = composer(base, options);
if (!exigeListe) return nomDeRang(1);
const pris = new Set(Array.from(leDossier.existants, cle));
let rang = 1;
let candidat = nomDeRang(rang);
while (pris.has(cle(candidat))) {
rang += 1;
candidat = nomDeRang(rang);
}
if (!IMPOSITIONS.includes(imposition)) throw new TypeError(`nomPlanche : imposition inconnue ${JSON.stringify(imposition)}`);
const date = horodatage === null ? HORODATAGE_INCONNU : jourEtHeure(horodatage);
let rang = '';
if (reedition !== null) {
if (!Array.isArray(reedition?.ids)) throw new TypeError('nomPlanche : réédition sans tableau ids');
const k = reedition.ids.length;
if (k < 1 || k > REEDITION_MAX) throw new RangeError(`nomPlanche : réédition de ${k} badges, de 1 à ${REEDITION_MAX} admis`);
rang = `-${k}`;
}
return `${base}.badges${rang}_${papier}_${imposition}_${date}.pdf`;
return candidat;
}
/**
* Vrai quand le nom de la planche (nomPlanche), sous le dossier donné, se
* choisit parmi les entrées du dossier : pour une réédition, ou quand la
* borne raccourcit la base. Faux sans dossier. Les entrées du dossier n'y
* sont pas lues : l'appelant liste le dossier seulement quand il le faut.
*
* @param {string} base
* @param {Object} options celles de nomPlanche
* @returns {boolean}
* @throws {TypeError|RangeError} comme nomPlanche
*/
export function nomPlancheExigeListe(base, options) {
return composer(base, options).exigeListe;
}

View file

@ -29,6 +29,7 @@ import {
dossierCorbeille,
longueurRelativeMax,
nomPlanche,
nomPlancheExigeListe,
} from './noms.js';
// Les deux dossiers de travail possibles (§ 8.6) : le dossier du produit dans
@ -1020,6 +1021,15 @@ describe('déterminisme et pureté (§ 14.7)', () => {
dossierCorbeille('2026-10-06T19:02:11-04:00', ['2026-10-06_19-02-11']),
nomPlanche('Gala', { papier: 'A4', imposition: 'pile', horodatage: '2026-10-06T19:02:11-04:00', reedition: { ids: [4, 9] } }),
nomPlanche('Gala', { papier: 'A4', imposition: 'feuille', horodatage: null, reedition: null }),
nomPlanche('Gala', {
papier: 'A4',
imposition: 'pile',
horodatage: '2026-10-06T19:02:11-04:00',
reedition: { ids: [4] },
proposition: 2,
ordre: 'table',
dossier: { ...DOSSIERS[0], existants: ['gala.BADGES-p2-1_A4_pile_table_2026-10-06_19-02-11.pdf'] },
}),
];
assert.deepEqual(sansSourcesVariables(rendre), rendre());
});
@ -1110,6 +1120,173 @@ describe('nomPlanche : le nom du fichier de la planche (§ 11.6, § 8.6)', () =>
}
});
test('une proposition porte « -p<id> » après « .badges », avant le compte d’une réédition ; le retenu, rien : son nom ne change pas', () => {
const nom = (options) => nomPlanche(BASE, { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, reedition: null, ...options });
assert.equal(nom({ proposition: 2 }), `${BASE}.badges-p2_A4_feuille_2031-05-17_20-15-07.pdf`);
assert.equal(nom({ proposition: 2 ** 31 - 1 }), `${BASE}.badges-p2147483647_A4_feuille_2031-05-17_20-15-07.pdf`);
assert.equal(nom({ proposition: 2, reedition: { ids: [3, 8, 12] } }), `${BASE}.badges-p2-3_A4_feuille_2031-05-17_20-15-07.pdf`);
assert.equal(nom({ proposition: null }), `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`);
assert.equal(nom({}), `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`);
});
test('deux propositions de la même génération, donc du même horodatage : deux noms ; le retenu au même horodatage, un troisième', () => {
const nom = (proposition) => nomPlanche(BASE, { papier: 'Lettre', imposition: 'pile', horodatage: HORODATAGE, reedition: null, proposition });
const noms = [nom(1), nom(2), nom(null)];
assert.equal(new Set(noms).size, 3, noms.join(' | '));
// Sans horodatage, les trois se distinguent encore.
const inconnus = [1, 2, null].map((proposition) => nomPlanche(BASE, { papier: 'A4', imposition: 'feuille', horodatage: null, proposition }));
assert.equal(new Set(inconnus).size, 3, inconnus.join(' | '));
});
test('l’ordre par table porte « _table » après l’imposition ; l’ordre alphabétique, rien', () => {
const nom = (ordre) => nomPlanche(BASE, { papier: 'A4', imposition: 'pile', horodatage: HORODATAGE, reedition: null, ordre });
assert.equal(nom('table'), `${BASE}.badges_A4_pile_table_2031-05-17_20-15-07.pdf`);
assert.equal(nom('alphabetique'), `${BASE}.badges_A4_pile_2031-05-17_20-15-07.pdf`);
assert.equal(nom(undefined), `${BASE}.badges_A4_pile_2031-05-17_20-15-07.pdf`);
assert.equal(
nomPlanche(BASE, { papier: 'Lettre', imposition: 'feuille', horodatage: null, reedition: reeditionDe(2), proposition: 4, ordre: 'table' }),
`${BASE}.badges-p4-2_Lettre_feuille_table_horodatage-inconnu.pdf`,
);
});
test('une réédition ne remplace jamais un fichier du dossier : le premier rang libre, « _2 », « _3 »…, comparé sans égard à la casse ; une planche entière garde son nom', () => {
const dossier = (existants) => ({ ...DOSSIERS[0], existants });
const options = { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, reedition: reeditionDe(3) };
const libre = `${BASE}.badges-3_A4_feuille_2031-05-17_20-15-07.pdf`;
const rang = (n) => libre.replace(/\.pdf$/, `_${n}.pdf`);
assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([]) }), libre);
assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([`${BASE}.gtt.json`, 'corbeille']) }), libre);
assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([libre]) }), rang(2));
assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([rang(2), libre]) }), rang(3));
assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([libre.toUpperCase()]) }), rang(2));
// Un rang pris ne compte pas tant que le nom sans rang est libre.
assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([rang(2)]) }), libre);
// Une liste figée passe, et n'est pas modifiée.
const figee = Object.freeze([libre]);
assert.equal(nomPlanche(BASE, { ...options, dossier: { ...DOSSIERS[0], existants: figee } }), rang(2));
assert.deepEqual([...figee], [libre]);
// La planche entière du même placement reprend son nom : même placement, même planche.
const entiere = `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`;
assert.equal(nomPlanche(BASE, { ...options, reedition: null, dossier: dossier([entiere]) }), entiere);
});
test('le plus long nom marqué tient sous 259 unités à côté d’une base à la borne : la base s’y raccourcit, le suffixe reste entier ; le nom sans marque ne change pas', () => {
for (const dossier of DOSSIERS) {
const base = deriverBase(lettres(400), { ...dossier, existantes: [] });
const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: reeditionDe(9999), proposition: 2 ** 31 - 1, ordre: 'table' };
const sansDossier = nomPlanche(base, options);
const suffixe = sansDossier.slice(base.length);
assert.equal(suffixe, '.badges-p2147483647-9999_Lettre_feuille_table_2031-05-17_20-15-07.pdf');
// Dix rééditions de suite dans le même dossier : chacune prend le rang
// libre suivant, la base raccourcie d'autant.
const pris = [];
for (let rang = 1; rang <= 10; rang += 1) {
const nom = nomPlanche(base, { ...options, dossier: { ...dossier, existants: pris } });
const attendu = rang === 1 ? suffixe : suffixe.replace(/\.pdf$/, `_${rang}.pdf`);
assert.ok(nom.endsWith(attendu), nom);
// La tête raccourcie finit par le point de la coupe.
const tete = nom.slice(0, nom.length - attendu.length);
assert.ok(tete.length > 1 && tete.endsWith('.') && base.startsWith(tete.slice(0, -1)), tete);
const chemin = `${dossier.racine}${dossier.separateur}${nom}.ecriture`;
assert.equal(chemin.length, LONGUEUR_CHEMIN_MAX, `${dossier.racine} : ${chemin.length} unités`);
pris.push(nom);
}
assert.equal(new Set(pris).size, 10);
// Le nom du retenu, dans l'ordre alphabétique, ne bouge pas.
const retenu = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: null };
assert.equal(nomPlanche(base, { ...retenu, dossier: { ...dossier, existants: [] } }), nomPlanche(base, retenu));
}
});
test('deux événements dont les bases ne diffèrent qu’au-delà de la coupe : la planche entière du second prend un rang plutôt que de remplacer celle du premier ; un nom raccourci ne se confond pas avec un nom entier', () => {
const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: null, proposition: 12, ordre: 'table' };
const suffixe = nomPlanche('b', options).slice(1);
assert.equal(suffixe, '.badges-p12_Lettre_feuille_table_2031-05-17_20-15-07.pdf');
for (const dossier of DOSSIERS) {
// L'original et sa copie : la copie ne diffère que par « (2) », que la coupe retire.
const nom = `Souper des Lucioles du printemps, \u{E9}dition sp\u{E9}ciale ${lettres(400)}`;
const original = derive(nom, dossier);
const copie = derive(nom, dossier, [original]);
assert.ok(copie.endsWith(' (2)') && copie.length === original.length, copie);
const sous = (existants) => ({ ...options, dossier: { ...dossier, existants } });
const premier = nomPlanche(original, sous([]));
assert.notEqual(premier, original + suffixe, 'la base se raccourcit');
assert.ok(`${dossier.racine}${dossier.separateur}${premier}.ecriture`.length <= LONGUEUR_CHEMIN_MAX, premier);
// Sans le premier fichier dans le dossier, la copie prendrait le même nom :
// listé, il lui laisse le rang suivant.
assert.equal(nomPlanche(copie, sous([])), premier);
const second = nomPlanche(copie, sous([`${original}.gtt.json`, `${copie}.gtt.json`, premier]));
// Le rang allonge le suffixe : la tête se raccourcit d'autant.
const auRang2 = suffixe.replace(/\.pdf$/, '_2.pdf');
assert.ok(second.endsWith(`.${auRang2}`) && premier.startsWith(second.slice(0, -auRang2.length - 1)), second);
assert.ok(`${dossier.racine}${dossier.separateur}${second}.ecriture`.length <= LONGUEUR_CHEMIN_MAX, second);
assert.equal(nomPlanche(copie, sous([premier.toUpperCase()])), second);
// La planche entière raccourcie ne remplace pas davantage la sienne.
assert.equal(nomPlanche(original, sous([premier])), second);
// Une base égale à la tête raccourcie se nomme entière, autrement que le nom raccourci.
const tete = premier.slice(0, premier.length - suffixe.length);
assert.ok(tete.endsWith('.'), tete);
const courte = derive(tete.slice(0, -1), dossier);
assert.equal(courte, tete.slice(0, -1));
const entier = nomPlanche(courte, sous([premier]));
assert.equal(entier, courte + suffixe);
assert.notEqual(entier.toUpperCase(), premier.toUpperCase());
}
});
test('le nom se choisit parmi les entrées du dossier pour une réédition, ou pour une base qui se raccourcit ; jamais sans dossier', () => {
const dossier = { ...DOSSIERS[0], existants: [] };
const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: null, proposition: 12, ordre: 'table' };
const longue = derive(lettres(400), DOSSIERS[0]);
assert.equal(nomPlancheExigeListe(BASE, { ...options, dossier }), false);
assert.equal(nomPlancheExigeListe(BASE, { ...options, reedition: reeditionDe(2), dossier }), true);
assert.equal(nomPlancheExigeListe(longue, { ...options, dossier }), true);
assert.equal(nomPlancheExigeListe(longue, { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, dossier }), false);
assert.equal(nomPlancheExigeListe(longue, options), false);
assert.equal(nomPlancheExigeListe(longue, { ...options, reedition: reeditionDe(2) }), false);
assert.throws(() => nomPlancheExigeListe(longue, { ...options, papier: 'A5', dossier }), TypeError);
});
test('la coupe rend un nom bien formé : ni demi-paire, ni espace ou point avant le point de la coupe, ni nom réservé, et le chemin tient sous 259 unités', () => {
const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: null, proposition: 12, ordre: 'table' };
const suffixe = '.badges-p12_Lettre_feuille_table_2031-05-17_20-15-07.pdf';
// Le dossier dont la racine laisse exactement libres unités à la tête du nom :
// racine, séparateur, tête, point de la coupe, suffixe, « .ecriture ».
const dossierPour = (libres) => dossierDeLongueur(259 - 1 - libres - 1 - suffixe.length - '.ecriture'.length);
const debut = (n) => `Gala ${lettres(n - 5)}`;
const L = 40;
const cas = [
['une paire coupée en son milieu', L, `${debut(L - 1)}\u{1F389}\u{1F389}x`, debut(L - 1)],
['une coupe sur une espace', L, `${debut(L - 1)} f\u{EA}te`, debut(L - 1)],
['une coupe sur un point', L, `${debut(L - 1)}.f\u{EA}te`, debut(L - 1)],
['une coupe sur une espace et un point', L, `${debut(L - 2)} .f\u{EA}t`, debut(L - 2)],
['une coupe qui laisserait CON', 3, 'CON soir', 'CO'],
['une coupe qui laisserait COM1', 4, 'COM1 soi', 'COM'],
];
for (const [quoi, libres, base, attendue] of cas) {
const dossier = dossierPour(libres);
assert.equal(derive(base, dossier), base, `${quoi} : la base se dérive telle quelle`);
const nom = nomPlanche(base, { ...options, dossier: { ...dossier, existants: [] } });
assert.equal(nom, `${attendue}.${suffixe}`, quoi);
const tete = nom.slice(0, nom.length - suffixe.length - 1);
assert.ok(!/[\ud800-\udfff]/u.test(tete), `${quoi} : demi-paire dans ${JSON.stringify(tete)}`);
assert.ok(!/[ .]$/.test(tete), `${quoi} : ${JSON.stringify(tete)} finit par une espace ou un point`);
assert.equal(estReserve(nom), false, `${quoi} : ${nom}`);
assert.ok(`${dossier.racine}${dossier.separateur}${nom}.ecriture`.length <= LONGUEUR_CHEMIN_MAX, quoi);
}
});
test('une base qui ne laisse rien à couper sous une racine trop longue garde son nom entier : l’écriture refusée offrira « Enregistrer sous… »', () => {
// La racine la plus longue où « Gala » se dérive encore.
const dossier = dossierDeLongueur(LONGUEUR_CHEMIN_MAX - 1 - longueurRelativeMax('Gala'));
assert.equal(derive('Gala', dossier), 'Gala');
const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: reeditionDe(9999), proposition: 2 ** 31 - 1, ordre: 'table' };
assert.equal(nomPlanche('Gala', { ...options, dossier: { ...dossier, existants: [] } }), nomPlanche('Gala', options));
// Un nom qui tient ne se raccourcit pas.
const court = { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, proposition: 3 };
assert.equal(nomPlanche('Gala', { ...court, dossier: { ...dossier, existants: [] } }), 'Gala.badges-p3_A4_feuille_2031-05-17_20-15-07.pdf');
});
test('un domaine hors contrat lève sans rien rendre : base, papier, imposition ou horodatage, TypeError ; réédition vide, de plus de 9 999 badges ou d’une autre forme, RangeError ou TypeError', () => {
const valides = { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, reedition: null };
const fautes = [
@ -1127,6 +1304,15 @@ describe('nomPlanche : le nom du fichier de la planche (§ 11.6, § 8.6)', () =>
[TypeError, BASE, { ...valides, reedition: { ids: 'abc' } }],
[RangeError, BASE, { ...valides, reedition: { ids: [] } }],
[RangeError, BASE, { ...valides, reedition: reeditionDe(10000) }],
[TypeError, BASE, { ...valides, proposition: 0 }],
[TypeError, BASE, { ...valides, proposition: 1.5 }],
[TypeError, BASE, { ...valides, proposition: '2' }],
[TypeError, BASE, { ...valides, ordre: 'hasard' }],
[TypeError, BASE, { ...valides, ordre: null }],
[TypeError, BASE, { ...valides, dossier: { racine: 'E:\\data', separateur: '\\' } }],
[TypeError, BASE, { ...valides, dossier: { racine: 'E:\\data', separateur: ':', existants: [] } }],
[TypeError, BASE, { ...valides, dossier: { racine: 42, separateur: '/', existants: [] } }],
[TypeError, BASE, { ...valides, dossier: 'E:\\data' }],
];
for (const [type, base, options] of fautes) {
assert.throws(() => nomPlanche(base, options), type, `${JSON.stringify(base)} ${JSON.stringify(options).slice(0, 120)}`);