[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:
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
62
spec.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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')}`],
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ function modele(n, { brouillon = false, reedition = false } = {}) {
|
|||
});
|
||||
return {
|
||||
badges,
|
||||
source: 'retenu',
|
||||
proposition: null,
|
||||
evenement: EVENEMENT,
|
||||
horodatage: HORODATAGE,
|
||||
brouillon,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue