diff --git a/spec.md b/spec.md index d1f78ac..8258b2f 100644 --- a/spec.md +++ b/spec.md @@ -1444,8 +1444,12 @@ geste, jamais un minuteur » du § 8.2 : un zoom continu écrit une fois par dé jamais à chaque image. **Le papier s'écrit au geste qui le choisit.** Les deux passent par une même file, qui relit le fichier avant chaque écriture : deux écrivains qui le liraient chacun avant que l'autre l'ait écrit perdraient l'un -des deux réglages. Un fichier que la lecture ne prend pas ne s'écrase pas : le -choix du papier se refuse, et le cadrage attend. +des deux réglages. Un fichier que la lecture ne prend pas ne s'écrase pas. Ni +lui, ni un dossier qui refuse de l'écrire, ne bloque le choix du papier : le +papier choisi vaut pour la séance sur ce dossier de travail, sans être retenu sur +la machine, et un avertissement nomme le fichier et dit que le choix n'est pas +retenu ; la première écriture des réglages qui y réussit le retient, et +l'avertissement se retire. Le cadrage attend. ### 8.6 Les fichiers diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index 81c6fcc..c135ff4 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -323,6 +323,7 @@ const EXEMPLES = new Map([ ['PLATEFORME_WEB', {}], ['SUPPORT_AMOVIBLE', {}], ['REGLAGES_ILLISIBLES', { chemin: 'reglages_locaux.json', raison: 'FORME' }], + ['REGLAGES_NON_ECRITS', { chemin: 'reglages_locaux.json', dossier: 'E:\\data', cause: 'EROFS' }], // CSV ['UTF16_SANS_MARQUE', { remede: 'ENREGISTRER_EN_UTF8' }], ['UTF16_INVALIDE', { remede: 'ENREGISTRER_EN_UTF8' }], @@ -700,6 +701,25 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => { assert.match(libelle('EVENEMENT_CHANGE', { revision: 7 }), /\b7\b/); }); + test("des réglages locaux illisibles nomment le fichier, disent le papier choisi pour la séance et le cadrage non retenu, sans promettre de valeurs par défaut", () => { + for (const raison of ['JSON', 'FORME', 'EACCES']) { + const texte = libelle('REGLAGES_ILLISIBLES', { chemin: 'reglages_locaux.json', raison }); + assert.match(texte, /« reglages_locaux\.json »/, raison); + assert.ok(texte.includes('le papier choisi vaut pour cette séance, sans être retenu sur cette machine'), `${raison} : « ${texte} »`); + assert.ok(texte.includes("le cadrage n'est pas retenu"), `${raison} : le cadrage ne se promet pas pour la séance`); + assert.doesNotMatch(texte, /défaut|cadrage choisis?/, raison); + } + assert.match(libelle('REGLAGES_ILLISIBLES', { chemin: 'reglages_locaux.json', raison: 'EACCES' }), /\(EACCES\)/); + }); + + test("des réglages locaux non écrits nomment le fichier, le dossier et la cause, et disent le papier non retenu sur la machine", () => { + const texte = libelle('REGLAGES_NON_ECRITS', { chemin: 'reglages_locaux.json', dossier: 'E:\\data', cause: 'EROFS' }); + for (const motif of [/« reglages_locaux\.json »/, /« E:\\data »/, /\(EROFS\)/]) assert.match(texte, motif); + assert.ok(texte.includes('sans être retenu sur cette machine'), texte); + assert.doesNotMatch(texte, /défaut|ne se lisent pas/); + assert.doesNotMatch(libelle('REGLAGES_NON_ECRITS', { chemin: 'reglages_locaux.json', dossier: 'E:\\data', cause: null }), /\(/); + }); + test("quitter un événement au support rompu nomme « Écrire ailleurs », et ce qui serait perdu", () => { const details = { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }; for (const echec of ['ECRITURE', 'ETAT_NON_ECRIT', 'JALON_NON_ECRIT', 'VERROU_NON_ECRIT']) { diff --git a/src/application/libelles/seance.js b/src/application/libelles/seance.js index 2c1700f..bc0762b 100644 --- a/src/application/libelles/seance.js +++ b/src/application/libelles/seance.js @@ -153,6 +153,14 @@ export const TABLE = new Map([ `Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause, texte)} : ` + 'une autre séance de ce poste le reprendra une fois ce logiciel fermé.', ], + // Le papier choisi que le dossier de travail refuse d'écrire dans les + // réglages locaux : il vaut pour la séance, en mémoire. + [ + 'REGLAGES_NON_ECRITS', + (details, texte) => + `Les réglages locaux ${ouEcrire(details, texte)} n'ont pas pu s'écrire : ` + + 'le papier choisi vaut pour cette séance, sans être retenu sur cette machine.', + ], ['RIEN_A_DEFAIRE', () => "Rien à défaire : l'historique commence à cet instant."], ['RIEN_A_REFAIRE', () => 'Rien à refaire : seul un retour se refait, tant que nul geste ne le suit.'], ['INSTANT_NON_RESTITUABLE', ({ revision }) => `L'historique ne restitue plus l'instant ${revision}.`], diff --git a/src/application/libelles/stockage.js b/src/application/libelles/stockage.js index 5148b3b..c39c4f2 100644 --- a/src/application/libelles/stockage.js +++ b/src/application/libelles/stockage.js @@ -317,7 +317,11 @@ export const TABLE = new Map([ ({ chemin, raison }, texte) => { const pourquoi = raison === 'JSON' || raison === 'FORME' ? texte(raison) : `La lecture est refusée (${raison}).`; - return `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Les valeurs par défaut s'appliquent.`; + return ( + `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Le fichier reste tel quel : ` + + 'le papier choisi vaut pour cette séance, sans être retenu sur cette machine, ' + + "et le cadrage n'est pas retenu." + ); }, ], ]); diff --git a/src/application/seance.js b/src/application/seance.js index c8506fe..3912cfa 100644 --- a/src/application/seance.js +++ b/src/application/seance.js @@ -70,7 +70,13 @@ // (§ 8.5) : regarder n'écrit jamais l'événement. Le papier des planches, de // la machine et non du plan, s'y range aussi, dans tout mode, avec ou sans // événement (§ 8.5, § 11.2) ; une seule file y écrit, que le cadrage et le -// papier partagent. +// papier partagent. Des réglages que la lecture ne prend pas ne s'écrasent +// jamais : le cadrage attend, et le papier choisi vaut pour la séance, en +// mémoire, sans être retenu sur la machine. Il en va de même du papier d'une +// écriture que le dossier refuse : ni des réglages illisibles ni un dossier +// en lecture seule ne bloquent le choix du papier, et les sorties écrivent +// là où le dossier l'accepte — un dossier qui refuse l'écriture refuse aussi +// le PDF des planches. // // Chaque geste lit l'horloge une fois : l'instant de son entrée au journal // date aussi le placement qu'il change (§ 8.9, § 11.8). @@ -100,7 +106,15 @@ import { FORMAT, analyser } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { fil, reconstruire } from '../stockage/journal.js'; import { SUFFIXES } from '../stockage/noms.js'; -import { avecCadrage, avecPapier, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js'; +import { + FICHIER_REGLAGES, + avecCadrage, + avecPapier, + cadrageDe, + creerEcrivainDiffere, + ecrireReglages, + lireReglages, +} from '../stockage/reglages_locaux.js'; import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js'; import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js'; import { libelle } from './libelles.js'; @@ -238,9 +252,10 @@ export function creerSeance({ // formatLu : le format de l'état relu, null dès qu'un geste l'a écrit ; // retourAutomatique : le dernier retour en lecture est celui de // l'inactivité ; cadrage : celui de l'événement ouvert, lu ou demandé ; - // papier : celui que portent les réglages de la racine, relu à chaque - // changement de racine et après chaque écriture des réglages, null sans - // racine ou sans choix. + // papier : sur la racine du choix de la séance (choixSeance), le papier de + // ce choix ; sur une autre, celui que portent ses réglages, à défaut le + // papier du choix ; relu à chaque changement de racine et après chaque + // écriture des réglages, null sans racine ou sans choix. const etat = { racine, ouvert: null, @@ -377,6 +392,31 @@ export function creerSeance({ // écriture et rendue après remettrait dans l'état le papier d'avant. let fileReglages = Promise.resolve(); + // Le choix de la séance, {racine, papier} : le dernier papier choisi sur + // des réglages que la lecture ne prend pas, ou que le dossier refuse + // d'écrire, et la racine où il l'a été ; le fichier ne le retient donc + // pas. Sur cette racine, il l'emporte sur le papier que le fichier porte ; + // sur une autre, il ne vaut que là où les réglages n'en portent aucun. + // null sans tel choix, et dès qu'une écriture le retient (retenirPapier). + let choixSeance = null; + + // Le papier de dossier selon le choix de la séance : celui du choix sur sa + // racine, undefined ailleurs. + const papierDuChoix = (dossier) => + choixSeance !== null && memeRacine(choixSeance.racine, dossier) ? choixSeance.papier : undefined; + + // Un papier vient de s'écrire dans des réglages : le choix de la séance + // s'efface, et les avis REGLAGES_ILLISIBLES et REGLAGES_NON_ECRITS du + // fichier des réglages quittent ceux du geste, que le choix retenu + // dément. + function retenirPapier() { + choixSeance = null; + etat.avertissementsGeste = etat.avertissementsGeste.filter( + ({ code, details }) => + !((code === 'REGLAGES_ILLISIBLES' || code === 'REGLAGES_NON_ECRITS') && details?.chemin === FICHIER_REGLAGES), + ); + } + // Met travail à son tour dans la file des réglages et rend sa promesse ; // la file continue après un refus. function enFileReglages(travail) { @@ -394,34 +434,86 @@ export function creerSeance({ // (REGLAGES_ILLISIBLES : lecture refusée, JSON ou forme fautifs) ne // s'écrasent pas des défauts, qui perdraient le papier et les cadrages : // le refus lève, ErreurSeance de l'avertissement, et rien ne s'écrit. - // L'écriture faite, le papier de la séance est celui qu'elle a écrit quand - // dossier est la racine de la séance. + // Sur la racine du choix de la séance, le papier du choix s'écrit avec ce + // que transformer rend, et l'écriture faite le retient (retenirPapier). + // L'écriture faite, EtatSeance.papier est celui qu'elle a écrit, à défaut + // celui du choix de la séance, quand dossier est la racine de la séance. function ecrireReglagesLocaux(dossier, transformer) { return enFileReglages(async () => { const { reglages, avertissement } = await lireReglages(fs, dossier); if (avertissement !== null) throw new ErreurSeance(avertissement.code, avertissement.details); - const ecrits = transformer(reglages); + const choisi = papierDuChoix(dossier); + const transformes = transformer(reglages); + const ecrits = choisi === undefined ? transformes : avecPapier(transformes, choisi); await ecrireReglages(fs, dossier, ecrits); - if (memeRacine(dossier, etat.racine)) { - etat.papier = ecrits.papier; - publier(); - } + if (choisi !== undefined) retenirPapier(); + const surRacine = memeRacine(dossier, etat.racine); + if (surRacine) etat.papier = ecrits.papier ?? choixSeance?.papier ?? null; + if (surRacine || choisi !== undefined) publier(); }); } - // À son tour dans la file des réglages, relit le papier des réglages de - // dossier — null sans dossier, sans choix, et pour des réglages que la - // lecture ne prend pas — et le pose dans l'état quand dossier est encore - // la racine de la séance. Ne lève jamais ; ne publie pas : l'appelant - // est un travail de la file des appels, qui publie en finissant. + // À son tour dans la file des réglages, écrit papier dans les réglages + // de dossier, le retient (retenirPapier) et rend {avertissements: []}. Un + // papier qui ne s'écrit pas devient le choix de la séance sur dossier, et + // un avis le dit : des réglages que la lecture ne prend pas ne s'écrivent + // pas, REGLAGES_ILLISIBLES {chemin, raison} ; une écriture que le dossier + // refuse (ErreurStockage) laisse le fichier intact, REGLAGES_NON_ECRITS + // {chemin, dossier, cause}. L'avis rejoint ceux du geste — une fois, quel + // que soit le nombre de choix — et se rend, {avertissements: [avis]}. Dans + // tous les cas, EtatSeance.papier devient papier quand dossier est encore + // la racine de la séance : un choix parti avant un changement de racine + // ne se publie pas sur la nouvelle. Une autre erreur, faute du code, + // remonte sans rien poser. + function ecrirePapier(dossier, papier) { + return enFileReglages(async () => { + const { reglages, avertissement: illisibles } = await lireReglages(fs, dossier); + let avis = null; + if (illisibles === null) { + try { + await ecrireReglages(fs, dossier, avecPapier(reglages, papier)); + } catch (erreur) { + if (!(erreur instanceof ErreurStockage)) throw erreur; + avis = avertissement('REGLAGES_NON_ECRITS', { + chemin: FICHIER_REGLAGES, + dossier: dossier.chemin, + cause: erreur.details?.cause ?? null, + }); + } + } else { + avis = avertissement(illisibles.code, illisibles.details); + } + if (avis === null) { + retenirPapier(); + } else { + choixSeance = { racine: dossier, papier }; + const meme = JSON.stringify(avis); + etat.avertissementsGeste = [...etat.avertissementsGeste.filter((autre) => JSON.stringify(autre) !== meme), avis]; + } + if (memeRacine(dossier, etat.racine)) etat.papier = papier; + publier(); + return { avertissements: avis === null ? [] : [avis] }; + }); + } + + // À son tour dans la file des réglages, pose dans l'état le papier de + // dossier, quand dossier est encore la racine de la séance : sur la racine + // du choix de la séance, le papier du choix, sans rien lire ; ailleurs, + // celui des réglages, à défaut celui du choix — sans papier, et pour des + // réglages que la lecture ne prend pas ; null sans dossier. Ne lève + // jamais ; ne publie pas : l'appelant est un travail de la file des + // appels, qui publie en finissant. function relirePapier(dossier) { return enFileReglages(async () => { let papier = null; if (dossier !== null) { - try { - papier = (await lireReglages(fs, dossier)).reglages.papier; - } catch { - papier = null; + papier = papierDuChoix(dossier); + if (papier === undefined) { + try { + papier = (await lireReglages(fs, dossier)).reglages.papier ?? choixSeance?.papier ?? null; + } catch { + papier = choixSeance?.papier ?? null; + } } } if (memeRacine(dossier, etat.racine)) etat.papier = papier; @@ -1329,23 +1421,29 @@ export function creerSeance({ * récent : l'état et le journal ne s'écrivent pas. Le domaine est celui * d'avecPapier ; null, qui y efface le choix, ne se choisit pas. * + * Des réglages que la lecture ne prend pas ne s'écrasent pas, et une + * écriture que le dossier refuse laisse le fichier intact ; ni l'un ni + * l'autre ne bloque le choix : le papier vaut pour la séance, en + * mémoire, EtatSeance.papier le porte, et l'avertissement + * REGLAGES_ILLISIBLES {chemin, raison}, ou REGLAGES_NON_ECRITS {chemin, + * dossier, cause}, rejoint EtatSeance.avertissements et se rend. Sur + * cette racine, ce papier l'emporte sur celui que le fichier porte, et + * la première écriture des réglages qui y réussit l'écrit et retire + * l'avertissement. Une séance neuve ne le retrouve pas. + * * @param {'A4'|'Lettre'} papier - * @returns {Promise} + * @returns {Promise<{avertissements: Array<{code: string, details: Object}>}>} + * vide quand le papier s'est écrit * @throws {TypeError} un papier hors du domaine, ou une séance sans * dossier de travail, sans rien lire ni écrire - * @throws {ErreurSeance} REGLAGES_ILLISIBLES {chemin, raison} : les - * réglages ne se lisent pas, ni ne s'écrasent ; le papier de la - * séance reste celui d'avant - * @throws {ErreurStockage} ECRITURE, le fichier intact */ async choisirPapier(papier) { if (papier === null) throw new TypeError('choisirPapier : un papier attendu, reçu null'); - const choisir = (reglages) => avecPapier(reglages, papier); // Essayé sur des réglages vides, avecPapier lève sur un papier hors du // domaine avant que rien ne se lise. - choisir({ format: 1, papier: null, cadrages: [] }); + avecPapier({ format: 1, papier: null, cadrages: [] }, papier); if (etat.racine === null) throw new TypeError('choisirPapier : aucun dossier de travail'); - await ecrireReglagesLocaux(etat.racine, choisir); + return ecrirePapier(etat.racine, papier); }, // quitter.abandonner : vrai pour quitter l'événement d'un support rompu diff --git a/src/application/seance.test.js b/src/application/seance.test.js index d021035..0423abd 100644 --- a/src/application/seance.test.js +++ b/src/application/seance.test.js @@ -11,7 +11,7 @@ // version vient du module engendré. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { creerFichiersSimules } from '../../test/fichiers_simules.js'; +import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js'; import { H_RETENU, personne as personneBadge, soiree, table as tableBadge } from '../../test/fixtures/badges/cas.js'; import { describe, test } from '../../test/lanceur.js'; import { lirePolices } from '../../test/polices.js'; @@ -3045,16 +3045,287 @@ describe('séance : le papier de la machine (§ 8.5, § 11.2, Q1)', () => { assert.equal(seance.etat().papier, null); }); - test('des réglages illisibles refusent le choix REGLAGES_ILLISIBLES, comme le cadrage : le fichier intact, le papier inchangé', async () => { - for (const corrompu of ['pas du json\n', '{"cadrages":[],"format":1,"papier":"A5"}\n']) { - const { fs, seance } = await ouvertSur(); - fs.deposer('documents', FICHIER_REGLAGES, corrompu); - await refus(seance.choisirPapier('A4'), 'REGLAGES_ILLISIBLES'); - assert.equal(fs.contenu('documents', FICHIER_REGLAGES), corrompu); - assert.equal(seance.etat().papier, null); + // Des réglages que la lecture ne prend pas : un format plus récent qui + // porte un papier inconnu de cette construction, et un texte tronqué. + const ILLISIBLES = [ + ['FORME', '{"format":2,"papier":"Legal","cadrages":[]}'], + ['JSON', '{"format":1,"papier":"A4","cadr'], + ]; + // Une soirée retenue de deux personnes à une table, un tour : une planche + // se prépare dès que le papier est connu. + const PLAN_RETENU = [{ sieges: [[1, 2]] }]; + const chargeRetenue = () => + soiree({ + personnes: [personneBadge(1, 'Iris', 'Ombrelle'), personneBadge(2, 'Théo', 'Pervenche')], + tables: [tableBadge(1, 1, 2)], + plan: PLAN_RETENU, + propositions: [[1, PLAN_RETENU]], + }); + const avecPlanche = { polices: async () => lirePolices(), provenance: 'production' }; + + test('des réglages illisibles : le papier choisi vaut pour la séance, en mémoire, le fichier intact octet pour octet, et la planche se prépare sans PAPIER_REQUIS', async () => { + for (const [raison, texte] of ILLISIBLES) { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, texte); + const { seance } = await ouvertSur(chargeRetenue(), { fs, ...avecPlanche }); + const avant = fs.compteurs.ecritures; + const textes = evenementSurDisque(fs); + const avis = { code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } }; + assert.deepEqual(await seance.choisirPapier('A4'), { avertissements: [avis] }, raison); + assert.equal(seance.etat().papier, 'A4', raison); + assert.deepEqual(seance.etat().avertissements.filter(({ code }) => code === avis.code), [avis], raison); + const annonce = await seance.preparerPlanche(); + assert.deepEqual([annonce.refus, annonce.papier], [null, 'A4'], raison); + await seance.choisirPapier('Lettre'); + assert.equal(seance.etat().papier, 'Lettre', raison); + assert.equal((await seance.preparerPlanche()).papier, 'Lettre', raison); + assert.deepEqual(seance.etat().avertissements.filter(({ code }) => code === avis.code), [avis], `${raison} : un avis, pas deux`); + assert.deepEqual(fs.octets('documents', FICHIER_REGLAGES), new TextEncoder().encode(texte), raison); + assert.equal(fs.compteurs.ecritures, avant, raison); + assert.deepEqual(evenementSurDisque(fs), textes, raison); } }); + test('des réglages illisibles : la planche se produit sur le papier de la séance, et seul le PDF s’écrit', async () => { + const [, texte] = ILLISIBLES[1]; + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, texte); + const { seance } = await ouvertSur(chargeRetenue(), { fs, ...avecPlanche }); + await refus(seance.produirePlanche(), 'PAPIER_REQUIS'); + await seance.choisirPapier('A4'); + const textes = evenementSurDisque(fs); + const { nom } = await seance.produirePlanche(); + assert.match(nom, /_A4_/); + assert.ok(fs.octets('documents', nom).length > 0); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte); + assert.deepEqual(evenementSurDisque(fs), textes); + }); + + test("le papier de la séance n'est pas retenu sur la machine : une séance neuve sur la même racine le demande, et un changement de racine le garde", async () => { + const [, texte] = ILLISIBLES[0]; + const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, texte); + const { seance } = await ouvertSur(chargeEssai(), { fs }); + await seance.choisirPapier('A4'); + const neuve = monter({ fs, prefixe: 'neuve' }); + await neuve.seance.attendre(); + assert.equal(neuve.seance.etat().papier, null); + await seance.changerRacine(DOC); + assert.equal(seance.etat().papier, 'A4', 'relu sur la même racine illisible'); + fs.deposerDossier(PORTABLE.id); + await seance.changerRacine(PORTABLE); + assert.equal(seance.etat().papier, 'A4', 'une racine sans réglages : le choix de la séance'); + fs.deposer(PORTABLE.id, FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":"Lettre"}\n'); + await seance.changerRacine(DOC); + await seance.changerRacine(PORTABLE); + assert.equal(seance.etat().papier, 'Lettre', 'un papier écrit sur la racine l’emporte'); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte); + }); + + test('des réglages illisibles : le cadrage différé attend, rien ne s’écrit, et le choix du papier ne le change pas', async () => { + const [, texte] = ILLISIBLES[1]; + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, texte); + const { seance, planificateur } = await ouvertSur(chargeEssai(), { fs }); + seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); + await planificateur.avancer(DELAI_REGLAGES_MS); + await seance.choisirPapier('Lettre'); + await laisser(); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte); + assert.equal(seance.etat().papier, 'Lettre'); + }); + + test('un cadrage écrit après des réglages illisibles réparés écrit avec lui le papier de la séance, qui devient retenu, et l’avis se retire', async () => { + const [, texte] = ILLISIBLES[1]; + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, texte); + const { seance, planificateur } = await ouvertSur(chargeEssai(), { fs }); + await seance.choisirPapier('A4'); + fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":"Lettre"}\n'); + seance.demanderCadrage({ k: 2, tx: 0, ty: 0 }); + await planificateur.avancer(DELAI_REGLAGES_MS); + await laisser(); + const relus = JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)); + assert.deepEqual([relus.papier, relus.cadrages.length], ['A4', 1]); + assert.equal(seance.etat().papier, 'A4'); + assert.equal(seance.etat().avertissements.some(({ code }) => code === 'REGLAGES_ILLISIBLES'), false); + const neuve = monter({ fs, prefixe: 'neuve' }); + await neuve.seance.attendre(); + assert.equal(neuve.seance.etat().papier, 'A4', 'retenu sur la machine'); + }); + + test('un papier écrit après des réglages illisibles réparés : le fichier le porte, la séance suit le fichier, et le papier de la séance s’efface', async () => { + const [, texte] = ILLISIBLES[1]; + const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, texte); + const { seance } = await ouvertSur(chargeEssai(), { fs }); + await seance.choisirPapier('A4'); + fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":null}\n'); + assert.deepEqual(await seance.choisirPapier('Lettre'), { avertissements: [] }); + assert.deepEqual([papierLu(fs), seance.etat().papier], ['Lettre', 'Lettre']); + assert.equal(seance.etat().avertissements.some(({ code }) => code === 'REGLAGES_ILLISIBLES'), false, 'l’avis se retire'); + fs.deposerDossier(PORTABLE.id); + await seance.changerRacine(PORTABLE); + assert.equal(seance.etat().papier, null, 'le choix retenu reste à sa racine ; une racine sans réglages le demande'); + }); + + // Un dossier de travail qui refuse toute écriture (EROFS) après + // l'ouverture : les réglages s'y lisent, ils ne s'y écrivent pas. + const avisNonEcrits = { code: 'REGLAGES_NON_ECRITS', details: { chemin: FICHIER_REGLAGES, dossier: DOCUMENTS, cause: 'EROFS' } }; + + test("un dossier qui refuse l'écriture des réglages : le papier choisi vaut pour la séance, un avis le dit, et la planche se prépare sans PAPIER_REQUIS", async () => { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":null}\n'); + const { seance } = await ouvertSur(chargeRetenue(), { fs, ...avecPlanche }); + fs.pannes.ecriture('documents'); + const textes = evenementSurDisque(fs); + const reglages = fs.contenu('documents', FICHIER_REGLAGES); + assert.deepEqual(await seance.choisirPapier('A4'), { avertissements: [avisNonEcrits] }); + assert.equal(seance.etat().papier, 'A4'); + const annonce = await seance.preparerPlanche(); + assert.deepEqual([annonce.refus, annonce.papier], [null, 'A4']); + assert.deepEqual(await seance.choisirPapier('Lettre'), { avertissements: [avisNonEcrits] }); + assert.equal((await seance.preparerPlanche()).papier, 'Lettre'); + assert.deepEqual( + seance.etat().avertissements.filter(({ code }) => code === avisNonEcrits.code), + [avisNonEcrits], + 'un avis, pas deux', + ); + assert.equal(seance.etat().avertissements.some(({ code }) => code === 'REGLAGES_ILLISIBLES'), false); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), reglages); + assert.deepEqual(evenementSurDisque(fs), textes); + }); + + test("un dossier qui refuse l'écriture des réglages : la production ne demande plus le papier, seule l'écriture du PDF est refusée", async () => { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + const { seance } = await ouvertSur(chargeRetenue(), { fs, ...avecPlanche }); + fs.pannes.ecriture('documents'); + await refus(seance.produirePlanche(), 'PAPIER_REQUIS'); + const textes = evenementSurDisque(fs); + await seance.choisirPapier('A4'); + const erreur = await refus(seance.produirePlanche(), 'PLANCHE_NON_ECRITE'); + assert.match(erreur.details.chemin, /_A4_/); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), null); + assert.deepEqual(evenementSurDisque(fs), textes); + }); + + test("le papier d'une écriture refusée vaut au changement de racine, là où les réglages n'en portent pas", async () => { + const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); + const { seance } = await ouvertSur(chargeEssai(), { fs }); + fs.pannes.ecriture('documents'); + await seance.choisirPapier('Lettre'); + fs.deposerDossier(PORTABLE.id); + await seance.changerRacine(PORTABLE); + assert.equal(seance.etat().papier, 'Lettre'); + }); + + // Des réglages lisibles qui portent déjà un papier, sur la racine des + // documents. + const REGLAGES_A4 = '{"cadrages":[],"format":1,"papier":"A4"}\n'; + const sansReglages = (seance) => + seance.etat().avertissements.filter(({ code }) => code === 'REGLAGES_NON_ECRITS' || code === 'REGLAGES_ILLISIBLES'); + + test("une écriture refusée une fois : le papier de la séance l'emporte sur celui du fichier, le cadrage écrit ensuite l'écrit avec lui, et l'avis se retire", async () => { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, REGLAGES_A4); + const { seance, planificateur } = await ouvertSur(chargeEssai(), { fs }); + fs.pannes.renommage('documents', 11); + const avis = { code: 'REGLAGES_NON_ECRITS', details: { chemin: FICHIER_REGLAGES, dossier: DOCUMENTS, cause: 'EBUSY' } }; + assert.deepEqual(await seance.choisirPapier('Lettre'), { avertissements: [avis] }); + assert.deepEqual([papierLu(fs), seance.etat().papier], ['A4', 'Lettre']); + seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); + await planificateur.avancer(DELAI_REGLAGES_MS); + await laisser(); + const relus = JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)); + assert.deepEqual([relus.papier, relus.cadrages.length], ['Lettre', 1]); + assert.equal(seance.etat().papier, 'Lettre'); + assert.deepEqual(sansReglages(seance), [], 'le choix est retenu : l’avis se retire'); + await seance.changerRacine(DOC); + assert.equal(seance.etat().papier, 'Lettre'); + }); + + test("une écriture refusée une fois, puis un papier qui s'écrit : l'avis se retire de l'état", async () => { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, REGLAGES_A4); + const { seance } = await ouvertSur(chargeEssai(), { fs }); + fs.pannes.renommage('documents', 11); + await seance.choisirPapier('Lettre'); + assert.deepEqual(sansReglages(seance).map(({ code }) => code), ['REGLAGES_NON_ECRITS']); + assert.deepEqual(await seance.choisirPapier('Lettre'), { avertissements: [] }); + assert.deepEqual([papierLu(fs), seance.etat().papier], ['Lettre', 'Lettre']); + assert.deepEqual(sansReglages(seance), []); + }); + + test("un dossier qui refuse l'écriture : revenir sur la même racine garde le papier de la séance, que le fichier en porte un autre ou non ; une autre racine lit le sien", async () => { + const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, REGLAGES_A4); + const { seance, planificateur } = await ouvertSur(chargeEssai(), { fs }); + fs.pannes.ecriture('documents'); + await seance.choisirPapier('Lettre'); + await seance.changerRacine(DOC); + assert.equal(seance.etat().papier, 'Lettre', 'la même racine'); + fs.deposerDossier(PORTABLE.id); + fs.deposer(PORTABLE.id, FICHIER_REGLAGES, REGLAGES_A4); + await seance.changerRacine(PORTABLE); + assert.equal(seance.etat().papier, 'A4', 'une autre racine : son papier l’emporte'); + await seance.changerRacine(DOC); + assert.equal(seance.etat().papier, 'Lettre', 'de retour sur la racine du choix'); + await seance.ouvrir(BASE); + seance.demanderCadrage({ k: 2, tx: 0, ty: 0 }); + await planificateur.avancer(DELAI_REGLAGES_MS); + await laisser(); + assert.equal(seance.etat().papier, 'Lettre', 'un cadrage refusé ne le change pas'); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), REGLAGES_A4); + }); + + test('une écriture des réglages qui lève autre chose qu’un refus du stockage remonte de choisirPapier, sans poser de papier ni d’avis', async () => { + const { fs, seance } = await ouvertSur(); + // Les réglages se lisent ; leur écriture lève une panne du processus. + const ecrireAtomique = fs.ecrireAtomique; + fs.ecrireAtomique = async (racine, chemin, texte) => { + if (chemin === FICHIER_REGLAGES) throw new PanneSimulee(); + return ecrireAtomique(racine, chemin, texte); + }; + await assert.rejects(seance.choisirPapier('A4'), PanneSimulee); + assert.equal(seance.etat().papier, null); + assert.deepEqual(sansReglages(seance), []); + }); + + test("un papier écrit pour l'ancienne racine, dans la file pendant un changement de racine, ne se publie jamais sur la nouvelle", async () => { + const { fs, seance } = monter({ fs: creerFichiersSimules({ executable: 'E:\\soirees' }) }); + await seance.attendre(); + // Un fichier présent : sa lecture se retient, celle d'un absent non. + fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":null}\n'); + fs.deposerDossier(PORTABLE.id); + const publies = []; + seance.subscribe(({ racine, papier }) => publies.push([racine?.id ?? null, papier])); + const lecture = retenirLectureReglages(fs); + const choix = seance.choisirPapier('Lettre'); + await laisser(); + assert.equal(lecture.prise(), true, 'la lecture du choix est partie'); + const changement = seance.changerRacine(PORTABLE); + await laisser(); + assert.equal(seance.racine.id, PORTABLE.id, 'la racine a changé, le choix attend'); + lecture.liberer(); + await choix; + await changement; + await seance.attendre(); + assert.equal(papierLu(fs), 'Lettre', 'écrit à sa racine'); + assert.deepEqual(publies.filter(([racine, papier]) => racine === PORTABLE.id && papier !== null), []); + assert.equal(seance.etat().papier, null); + }); + test("un cadrage différé et le choix du papier écrits ensemble : les deux restent, l'un après l'autre", async () => { const { fs, seance, planificateur } = await ouvertSur(); seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); diff --git a/src/interface/impression/ReglagesIllisibles.navigateur.test.js b/src/interface/impression/ReglagesIllisibles.navigateur.test.js new file mode 100644 index 0000000..657b5b4 --- /dev/null +++ b/src/interface/impression/ReglagesIllisibles.navigateur.test.js @@ -0,0 +1,110 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'aperçu avant impression sur des réglages locaux que la lecture ne prend +// pas, ou que le dossier de travail refuse d'écrire (§ 8.5, § 11.2, § 11.7) : +// la question du papier se pose, le choix vaut pour la séance sans que +// reglages_locaux.json le retienne, et l'aperçu rend son document, sur le +// papier choisi, et sa commande « Imprimer » au lieu de reposer la question. +// Rien ne s'écrit : ni le fichier illisible, ni l'événement ; sous un dossier +// qui refuse l'écriture, la seule tentative est celle des réglages. +// +// App est montée par test/navigateur/ecrans.js sur le système de fichiers +// simulé, au cadre de 1 366 × 700. +// +// Le projet navigateur emploie expect, et test et describe de Vitest +// (§ 14.8). +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { PAPIERS } from '../../geometrie/papiers.js'; +import { FICHIER_REGLAGES } from '../../stockage/reglages_locaux.js'; +import { fixerCadre } from '../../../test/navigateur/entrees.js'; +import { attendre, chargePrincipale, cliquer, creerContexte, ouvrirApercu } from '../../../test/navigateur/ecrans.js'; +import { creerImprimanteSimulee } from '../../../test/imprimante_simulee.js'; +import { MM_PAR_PX } from './modele.js'; + +// Images attendues au plus par étape, comme dans Apercu.navigateur.test.js. +const IMAGES = 300; + +// Une feuille se mesure au dixième de millimètre (§ 11.7). +const TOLERANCE_MM = 0.1; + +let contexte = null; +beforeEach(async () => { + await fixerCadre(1366, 700); + contexte = creerContexte(); +}); +afterEach(async () => { + const erreurs = contexte.erreurs(); + await contexte.defaire(); + expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]); +}); + +const dans = (m, selecteur) => m.racine.querySelector(selecteur); +const feuilles = (m) => [...m.racine.querySelectorAll('[data-impression] [data-feuille]')]; + +// Des réglages que la lecture ne prend pas : un format plus récent qui porte +// un papier inconnu de cette construction, et un texte tronqué. +const ILLISIBLES = [ + ['FORME', '{"format":2,"papier":"Legal","cadrages":[]}'], + ['JSON', '{"format":1,"papier":"A4","cadr'], +]; + +describe('des réglages locaux illisibles ne bloquent pas l’aperçu (§ 8.5, § 11.7)', () => { + for (const [raison, texte] of ILLISIBLES) { + test(`${raison} : le papier choisi, l’aperçu montre le document et « Imprimer » au lieu de la question ; le fichier intact, rien ne s’écrit`, async () => { + const imprimante = creerImprimanteSimulee(); + const m = await ouvrirApercu(contexte, chargePrincipale(), { + papier: null, + options: { imprimante, fichiers: [{ racine: 'documents', chemin: FICHIER_REGLAGES, texte }] }, + }); + const octets = m.fs.octets('documents', FICHIER_REGLAGES); + expect(dans(m, '[data-ecran="apercu"] [data-papier="question"]')).not.toBeNull(); + expect(dans(m, '[data-impression]')).toBeNull(); + const avant = m.trace.length; + + await cliquer(m, '[data-papier="question"] [data-cible="A4"] [data-commande="choisirPapier"]'); + await attendre('le document sur A4', () => feuilles(m).length > 0, IMAGES); + expect(m.seance.etat().papier).toBe('A4'); + const boite = feuilles(m)[0].getBoundingClientRect(); + const dims = [boite.width * MM_PAR_PX, boite.height * MM_PAR_PX].sort((a, b) => a - b); + expect(Math.abs(dims[0] - PAPIERS.A4.largeur)).toBeLessThanOrEqual(TOLERANCE_MM); + expect(Math.abs(dims[1] - PAPIERS.A4.hauteur)).toBeLessThanOrEqual(TOLERANCE_MM); + expect(dans(m, '[data-papier="question"]')).toBeNull(); + expect(dans(m, '[data-ecran="apercu"] [data-refus]')).toBeNull(); + const avis = m.seance.etat().avertissements.filter(({ code }) => code === 'REGLAGES_ILLISIBLES'); + expect(avis).toEqual([{ code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } }]); + + await cliquer(m, '[data-ecran="apercu"] [data-commande="imprimer"]'); + await attendre("l'impression demandée", () => imprimante.appels().length === 1, IMAGES); + expect(imprimante.appels()[0].papier).toBe('A4'); + expect(m.trace.slice(avant)).toEqual([]); + expect(m.fs.octets('documents', FICHIER_REGLAGES)).toEqual(octets); + }); + } +}); + +describe('un dossier qui refuse l’écriture des réglages ne bloque pas l’aperçu (§ 8.5, § 11.7)', () => { + test('EROFS : le papier choisi, l’aperçu montre le document et « Imprimer » ; un avis le dit, et rien ne s’écrit', async () => { + const imprimante = creerImprimanteSimulee(); + const m = await ouvrirApercu(contexte, chargePrincipale(), { papier: null, options: { imprimante } }); + expect(dans(m, '[data-ecran="apercu"] [data-papier="question"]')).not.toBeNull(); + m.fs.pannes.ecriture('documents'); + const avant = m.trace.length; + + await cliquer(m, '[data-papier="question"] [data-cible="A4"] [data-commande="choisirPapier"]'); + await attendre('le document sur A4', () => feuilles(m).length > 0, IMAGES); + expect(m.seance.etat().papier).toBe('A4'); + expect(dans(m, '[data-papier="question"]')).toBeNull(); + expect(dans(m, '[data-ecran="apercu"] [data-refus]')).toBeNull(); + const avis = m.seance.etat().avertissements.filter(({ code }) => code === 'REGLAGES_NON_ECRITS'); + expect(avis).toEqual([ + { code: 'REGLAGES_NON_ECRITS', details: { chemin: FICHIER_REGLAGES, dossier: expect.any(String), cause: 'EROFS' } }, + ]); + + await cliquer(m, '[data-ecran="apercu"] [data-commande="imprimer"]'); + await attendre("l'impression demandée", () => imprimante.appels().length === 1, IMAGES); + expect(imprimante.appels()[0].papier).toBe('A4'); + expect(m.trace.slice(avant)).toEqual([['ecrireAtomique', 'documents', FICHIER_REGLAGES]]); + expect(m.fs.contenu('documents', FICHIER_REGLAGES)).toBeNull(); + }); +}); diff --git a/src/interface/sorties/Papier.svelte b/src/interface/sorties/Papier.svelte index 78b168f..74c30d0 100644 --- a/src/interface/sorties/Papier.svelte +++ b/src/interface/sorties/Papier.svelte @@ -11,10 +11,13 @@ // rouvre la question. Aucun papier n'est choisi pour l'opérateur. // // Un choix passe par seance.choisirPapier, vivant dans tout mode, sur un - // plan bloqué comme en lecture. Un refus — des réglages illisibles, une - // écriture refusée — s'affiche sur le choix cliqué, par la commande, et - // la question reste. Le choix écrit, onChoisi, quand il est donné, reçoit - // le papier : la section qui demandait une production la poursuit. + // plan bloqué comme en lecture. Des réglages illisibles ou une écriture + // refusée ne refusent pas le choix : il vaut pour la séance, et + // l'avertissement que rend la séance va à EtatSeance.avertissements, que + // ce composant n'affiche pas. Un refus s'affiche sur le choix cliqué, par + // la commande, et la question reste. Le choix fait, onChoisi, quand il est + // donné, reçoit le papier : la section qui demandait une production la + // poursuit. import { getContext } from 'svelte'; import Commande from '../primitives/Commande.svelte'; import { PAPIERS_OFFERTS, clesPapier } from '../impression/modele.js';