From 8b7c732399d711b35065aea6b30455c6bb89baa0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 9 Oct 2026 15:26:13 -0400 Subject: [PATCH] [ADD] platform: print bridge, atomic byte writes, badge sheet file name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Printed views and the badge sheet leave the app through the platform. Under Electron a gtt:imprimer channel prints with the paper and orientation asked for and reports sent, cancelled, failed or unknown; an exception is logged to the console and never shown raw to the operator. Every file system, real or simulated, writes bytes atomically from a copy taken at the call. The sheet name carries event, date and imposition, and a failed print becomes IMPRESSION_ECHEC. Checked: red first, twelve mutations caught; 3502 node, 659 browser and 79 long tests green from the index alone. --- FR --- [ADD] plateforme : pont d'impression, octets atomiques, nom de planche Les vues imprimables et la planche sortent de l'application par la plateforme. Sous Electron, un canal gtt:imprimer imprime au papier et à l'orientation demandés et rend envoyé, annulé, échec ou inconnu ; une exception va à la console, jamais brute à l'opérateur. Chaque système de fichiers, réel ou simulé, écrit des octets de façon atomique, depuis une copie prise à l'appel. Le nom de la planche porte événement, date et imposition, et une impression échouée devient IMPRESSION_ECHEC. Vérifié : rouge d'abord, douze mutations attrapées ; 3502 node, 659 navigateur et 79 longues vertes depuis l'index seul. Assisted-by: Claude Opus 5.5 --- electron/fichiers.js | 18 ++- electron/impression.js | 106 +++++++++++++ electron/main.js | 9 +- electron/preload.cjs | 17 +- src/application/libelles.test.js | 2 + src/application/libelles/sorties.js | 11 ++ src/application/sorties.js | 35 +++- src/application/sorties.test.js | 35 +++- src/impression/imprimante_electron.js | 58 +++++++ src/impression/imprimante_electron.test.js | 101 ++++++++++++ src/interface/App.svelte | 20 ++- src/main.js | 19 ++- src/stockage/fichiers_electron.js | 8 +- src/stockage/fichiers_web.js | 19 ++- src/stockage/fichiers_web.navigateur.test.js | 23 +++ src/stockage/noms.js | 90 +++++++++-- src/stockage/noms.test.js | 112 ++++++++++++- src/stockage/systeme_fichiers.js | 23 +++ src/stockage/systeme_fichiers.test.js | 93 ++++++++++- test/arborescence.test.js | 101 ++++++++++++ test/contrat_fichiers.js | 57 ++++++- test/coquille.test.js | 159 +++++++++++++++++-- test/electron_factice.js | 50 +++++- test/fichiers_electron.test.js | 111 ++++++++++++- test/fichiers_simules.js | 69 +++++--- test/imprimante_simulee.js | 52 ++++++ test/imprimante_simulee.test.js | 74 +++++++++ test/navigateur/montage.js | 23 ++- 28 files changed, 1391 insertions(+), 104 deletions(-) create mode 100644 electron/impression.js create mode 100644 src/impression/imprimante_electron.js create mode 100644 src/impression/imprimante_electron.test.js create mode 100644 test/imprimante_simulee.js create mode 100644 test/imprimante_simulee.test.js diff --git a/electron/fichiers.js b/electron/fichiers.js index 77b4423..f612054 100644 --- a/electron/fichiers.js +++ b/electron/fichiers.js @@ -1,7 +1,7 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Le système de fichiers de la coquille (§ 8.6, § 8.8, § 13.1) : les dix-sept +// Le système de fichiers de la coquille (§ 8.6, § 8.8, § 13.1) : les dix-huit // primitives de l'interface de src/stockage/systeme_fichiers.js, exécutées // sous Node par le processus principal, et le service qui les offre à la // page, un canal IPC par primitive, gtt:fichiers:. @@ -49,6 +49,7 @@ export const PRIMITIVES = Object.freeze([ 'typeSupport', 'lireTexte', 'ecrireAtomique', + 'ecrireOctetsAtomique', 'ajouterLigne', 'lister', 'creerDossier', @@ -718,6 +719,21 @@ export function creerFichiers({ } }, + // Les octets tels quels, par la même écriture atomique que le texte. Les + // octets que l'IPC a copiés sont à ce seul appel : ils s'écrivent sans + // autre copie. + async ecrireOctetsAtomique(racine, chemin, octets) { + if (!(octets instanceof Uint8Array)) throw new TypeError('les octets ne sont pas un Uint8Array'); + const loc = localiser(racine, chemin); + try { + await confiner(loc); + if ((await etatDe(loc.absolu))?.isDirectory()) throw refus(loc, 'EISDIR'); + await remplacer(loc.absolu, octets); + } catch (erreur) { + throw echecEcriture(loc, erreur); + } + }, + // Ouvre en ajout — le fichier absent se crée, son dossier jamais —, // écrit la ligne et sa fin de ligne, vide sur le disque et ferme. Seule // l'ouverture se réessaie : une écriture refusée a pu laisser une partie diff --git a/electron/impression.js b/electron/impression.js new file mode 100644 index 0000000..08adafc --- /dev/null +++ b/electron/impression.js @@ -0,0 +1,106 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'impression de la coquille (§ 11.7, § 13.4), dans le processus +// principal : le gestionnaire du canal gtt:imprimer, que +// electron/preload.cjs expose à la page sous window.gtt.imprimer. La page +// n'y demande qu'un papier et une orientation ; le gestionnaire en compose +// lui-même les options de webContents.print — boîte de dialogue du système +// montrée, fonds imprimés, format de page du papier, paysage ou non — et +// refuse toute autre demande sans rien imprimer : une option de print +// brute venue de la page, une impression silencieuse vers une imprimante de +// son choix par exemple, ne passe pas, comme electron/fichiers.js n'admet +// qu'un chemin relatif à une racine connue. +// +// Le paquet n'emporte de src/ que la page construite (§ 14.15) : la table +// des papiers se recopie ici, et test/coquille.test.js éprouve que ses clés +// sont celles de PAPIERS (src/geometrie/papiers.js). +// +// L'issue rendue à la page est celle du rappel de print : envoyé quand il +// réussit, annulé quand sa raison est « cancelled », celle que donne une +// boîte de dialogue que l'opérateur ferme, échoué sinon, la raison pour +// cause. Un print qui lève — un contenu détruit entre la demande et +// l'impression — est un échec sans cause : son message, une exception du +// moteur, va à la console du processus principal, jamais à l'opérateur. + +/** Le canal de l'impression, qu'invoque le préchargement. */ +export const CANAL_IMPRESSION = 'gtt:imprimer'; + +/** Le format de page de webContents.print, par papier : les clés de PAPIERS. */ +export const FORMATS_DE_PAGE = Object.freeze({ A4: 'A4', Lettre: 'Letter' }); + +// Les orientations d'une demande, et la valeur de landscape de chacune. +const PAYSAGE = new Map([ + ['portrait', false], + ['paysage', true], +]); + +// Les clés d'une demande, triées : papier et orientation, rien d'autre. +const CLES_DE_LA_DEMANDE = ['orientation', 'papier']; + +// La raison qu'Electron donne au rappel d'une impression que l'opérateur +// annule. +const RAISON_ANNULEE = 'cancelled'; + +/** + * Les options de webContents.print d'une demande de la page. + * + * @param {unknown} demande {papier, orientation}, telle que l'IPC l'a copiée + * @returns {{silent: false, printBackground: true, pageSize: string, landscape: boolean}} + * @throws {TypeError} une demande qui n'est pas un objet aux seules clés + * papier et orientation, un papier hors de FORMATS_DE_PAGE, une + * orientation autre que portrait ou paysage + */ +export function optionsImpression(demande) { + if (demande === null || typeof demande !== 'object' || Array.isArray(demande)) { + throw new TypeError("impression : la demande n'est pas un objet"); + } + const cles = Object.keys(demande).sort(); + if (cles.length !== CLES_DE_LA_DEMANDE.length || cles.some((cle, rang) => cle !== CLES_DE_LA_DEMANDE[rang])) { + throw new TypeError(`impression : options refusées ${JSON.stringify(cles)}, seuls papier et orientation admis`); + } + const { papier, orientation } = demande; + if (typeof papier !== 'string' || !Object.hasOwn(FORMATS_DE_PAGE, papier)) { + throw new TypeError(`impression : papier inconnu ${JSON.stringify(papier)}`); + } + if (!PAYSAGE.has(orientation)) throw new TypeError(`impression : orientation inconnue ${JSON.stringify(orientation)}`); + return { silent: false, printBackground: true, pageSize: FORMATS_DE_PAGE[papier], landscape: PAYSAGE.get(orientation) }; +} + +/** + * Imprime un contenu de fenêtre selon une demande de la page. + * + * @param {{print: Function}} contenu le webContents de la page qui demande + * @param {unknown} demande + * @returns {Promise<{issue: 'envoye'|'annule'|'echec', cause: string|null}>} + * cause : la raison d'un échec, null sinon, et null pour un échec + * sans raison + * @throws {TypeError} la demande que refuse optionsImpression, en rejet, sans + * appeler print + */ +export async function imprimer(contenu, demande) { + const options = optionsImpression(demande); + return new Promise((resoudre) => { + try { + contenu.print(options, (succes, raison) => { + if (succes) resoudre({ issue: 'envoye', cause: null }); + else if (raison === RAISON_ANNULEE) resoudre({ issue: 'annule', cause: null }); + else resoudre({ issue: 'echec', cause: typeof raison === 'string' && raison !== '' ? raison : null }); + }); + } catch (erreur) { + console.error('gtt:imprimer', erreur); + resoudre({ issue: 'echec', cause: null }); + } + }); +} + +/** + * Pose le gestionnaire du canal de l'impression : poser(canal, + * gestionnaire), ipcMain.handle dans le processus principal. Le + * gestionnaire imprime le contenu qui a envoyé la demande. + * + * @param {(canal: string, gestionnaire: Function) => void} poser + */ +export function servirImpression(poser) { + poser(CANAL_IMPRESSION, (evenement, demande) => imprimer(evenement.sender, demande)); +} diff --git a/electron/main.js b/electron/main.js index 572f31b..67c8681 100644 --- a/electron/main.js +++ b/electron/main.js @@ -6,7 +6,9 @@ // la plateforme (electron/fichiers.js), offert à la page par un canal IPC // par primitive, que electron/preload.cjs expose sous window.gtt.fichiers ; // un canal de plus, gtt:copier, remet un texte au presse-papiers du système -// (window.gtt.copier). +// (window.gtt.copier) ; un dernier, gtt:imprimer, imprime la page sur le +// papier et dans l'orientation qu'elle demande (electron/impression.js, +// window.gtt.imprimer). // // La page n'a ni Node ni accès au disque : isolation de contexte active, // intégration de Node coupée, rendu en bac à sable. Elle n'atteint le @@ -20,6 +22,7 @@ import { readdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { creerFichiers, decrireSysteme, processusVivant, servir } from './fichiers.js'; +import { servirImpression } from './impression.js'; // Chemins relatifs à ce module : ils valent dans le dépôt comme dans // l'archive app.asar de l'exécutable, qui reproduit electron/ et www/. @@ -83,6 +86,10 @@ ipcMain.handle('gtt:copier', (_evenement, texte) => { clipboard.writeText(texte); }); +// L'impression : la page qui la demande s'imprime, par la boîte de dialogue +// du système, sur le papier et dans l'orientation reçus, et rien d'autre. +servirImpression((canal, gestionnaire) => ipcMain.handle(canal, gestionnaire)); + // Les verrous que la page a pris et pas rendus s'effacent à la sortie (§ 8.8) : // la page ne survit pas au processus principal, et une fermeture de la fenêtre // ne lui laisse pas le temps de les rendre elle-même. rendreVerrousTenus les diff --git a/electron/preload.cjs b/electron/preload.cjs index 9c32b7d..ed0dfe9 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -7,15 +7,17 @@ // invoque le canal de cette primitive, gtt:fichiers:, et rend la // réponse du processus principal (electron/fichiers.js) ; gtt.copier(texte), // qui invoque le canal gtt:copier et remet le texte au presse-papiers du -// système. La page ne reçoit jamais ipcRenderer lui-même, ni une fonction -// qui invoquerait un canal de son choix. +// système ; gtt.imprimer({papier, orientation}), qui invoque le canal +// gtt:imprimer et rend l'issue de l'impression (electron/impression.js). La +// page ne reçoit jamais ipcRenderer lui-même, ni une fonction qui +// invoquerait un canal de son choix. // // Un préchargement exécuté en bac à sable est un script CommonJS, d'où // l'extension .cjs dans un paquet de type module. Son require ne connaît // qu'electron et quelques modules de Node, et non les fichiers du projet : -// les primitives s'écrivent donc ici, dans l'ordre de l'interface, et -// test/coquille.test.js éprouve qu'elles sont celles des canaux que pose le -// processus principal. +// les primitives et les noms des canaux s'écrivent donc ici, les primitives +// dans l'ordre de l'interface, et test/coquille.test.js éprouve qu'ils sont +// ceux des canaux que pose le processus principal. const { contextBridge, ipcRenderer } = require('electron'); const PRIMITIVES = [ @@ -26,6 +28,7 @@ const PRIMITIVES = [ 'typeSupport', 'lireTexte', 'ecrireAtomique', + 'ecrireOctetsAtomique', 'ajouterLigne', 'lister', 'creerDossier', @@ -46,4 +49,6 @@ for (const primitive of PRIMITIVES) { const copier = (texte) => ipcRenderer.invoke('gtt:copier', texte); -contextBridge.exposeInMainWorld('gtt', { fichiers, copier }); +const imprimer = (demande) => ipcRenderer.invoke('gtt:imprimer', demande); + +contextBridge.exposeInMainWorld('gtt', { fichiers, copier, imprimer }); diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index 2df7a4d..9b9fd29 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -551,6 +551,8 @@ const EXEMPLES = new Map([ ], ['PLANCHE_TROP_DE_TOURS', { tours: 40, remede: null }], ['PLANCHE_NOM_TROP_LONG', { participant: 3, personne: 'Ondine Lacasse', remede: { geste: 'ouvrirSection', section: 'participants' } }], + // Sorties + ['IMPRESSION_ECHEC', { cause: 'failed', remede: null }], ]); // Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni diff --git a/src/application/libelles/sorties.js b/src/application/libelles/sorties.js index 1650477..658a720 100644 --- a/src/application/libelles/sorties.js +++ b/src/application/libelles/sorties.js @@ -5,9 +5,20 @@ // imprimée ou la feuille de contrôle des badges, et que l'écran reprend. // L'écran les lit par t, le papier par libelle : chaque clé vit donc dans // une table de l'application, que les deux traductions réunissent. +import { causeEntreParentheses } from './aides.js'; + export const TABLE = new Map([ // La marque qui remplace la version dans une construction de // documentation (§ 19.4) : au bandeau, au premier écran, au pied des pages // imprimées et sur la feuille de contrôle. ['sorties.version.documentation', 'construction de documentation'], + + // L'impression que l'imprimante de la plateforme rend échouée + // (refusImpression, § 11.7, § 13.4) : la cause est la raison que donne le + // système d'impression, quand il en donne une. + [ + 'IMPRESSION_ECHEC', + ({ cause }, texte) => + `L'impression a échoué${causeEntreParentheses(cause, texte)} : vérifiez l'imprimante choisie, puis imprimez de nouveau.`, + ], ]); diff --git a/src/application/sorties.js b/src/application/sorties.js index 251a069..364b44d 100644 --- a/src/application/sorties.js +++ b/src/application/sorties.js @@ -5,9 +5,10 @@ // toute sortie — page imprimée, planche de badges, feuille de contrôle — lit // de la charge pour dire quel placement elle porte. L'horodatage du // placement, la règle du filigrane BROUILLON et le pied de page n'ont ici -// qu'une implémentation, que l'aperçu et la planche partagent (§ 13.2). -// Chaque fonction lit la charge et rien d'autre : ni horloge, ni disque, ni -// tirage ; deux appels sur la même charge rendent les mêmes valeurs. +// qu'une implémentation, que l'aperçu et la planche partagent (§ 13.2). Le +// refus que rend l'issue d'une impression s'y lit aussi. Chaque fonction lit +// ce qu'elle reçoit et rien d'autre : ni horloge, ni disque, ni tirage ; +// deux appels sur les mêmes arguments rendent les mêmes valeurs. // // Une sortie imprime l'une des trois sources de l'état des places : le // retenu, une proposition désignée par son identifiant, ou les seules @@ -119,3 +120,31 @@ export function piedDePage(charge, { source, proposition = null, tour, provenanc version: versionVisible(provenance, version, texte), }; } + +// Les issues que rend l'imprimante de la plateforme (§ 13.4) ; seul un échec +// porte une cause, une chaîne ou null. +const ISSUES_IMPRESSION = new Set(['envoye', 'annule', 'echec', 'inconnue']); + +/** + * Le refus que rend l'issue d'une impression (§ 11.7, § 13.4), que l'aperçu + * pose sur sa commande « Imprimer » : null pour une impression envoyée, + * annulée par l'opérateur ou d'issue inconnue, qui n'appellent aucun + * message ; pour un échec, IMPRESSION_ECHEC, qui porte la cause que donne le + * système d'impression — null quand il n'en donne aucune —, sans remède. + * + * @param {{issue: 'envoye'|'annule'|'echec'|'inconnue', cause: string|null}} resultat + * ce que rend Imprimante.imprimer + * @returns {import('./erreurs.js').ErreurCommande|null} + * @throws {TypeError} une issue inconnue, une cause absente, une cause qui + * n'est ni une chaîne ni null, une cause pour une issue autre qu'un + * échec + */ +export function refusImpression(resultat) { + if (resultat === null || typeof resultat !== 'object') throw new TypeError("refusImpression : l'issue n'est pas un objet"); + const { issue, cause } = resultat; + if (!ISSUES_IMPRESSION.has(issue)) throw new TypeError(`refusImpression : issue inconnue ${JSON.stringify(issue)}`); + if (!(cause === null || (issue === 'echec' && typeof cause === 'string'))) { + throw new TypeError(`refusImpression : cause ${JSON.stringify(cause)} pour l'issue ${issue}`); + } + return issue === 'echec' ? refus('IMPRESSION_ECHEC', { cause }) : null; +} diff --git a/src/application/sorties.test.js b/src/application/sorties.test.js index 9ecb385..b791ef0 100644 --- a/src/application/sorties.test.js +++ b/src/application/sorties.test.js @@ -6,7 +6,8 @@ // BROUILLON, sur la table complète des états et des sources ; le pied de // page, ses cinq éléments, la version remplacée par la marque dans une // construction de documentation, et aucune lecture d'horloge. Les noms -// d'épreuve sont inventés ; la version vient du module engendré. +// d'épreuve sont inventés ; la version vient du module engendré. Enfin le +// refus que rend l'issue d'une impression, que l'aperçu affiche. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { TABLES_INTERFACE } from '../interface/libelles.js'; @@ -15,7 +16,7 @@ import { VERSION } from '../version.genere.js'; import { ErreurCommande } from './erreurs.js'; import { creerHorlogePilotee } from './horloge.js'; import { TABLES_APPLICATION, libelle } from './libelles.js'; -import { SOURCES_SORTIE, estBrouillon, horodatagePlacement, piedDePage } from './sorties.js'; +import { SOURCES_SORTIE, estBrouillon, horodatagePlacement, piedDePage, refusImpression } from './sorties.js'; import { creerTraduction } from './traduction.js'; const H = '2031-05-17T20:15:00-04:00'; @@ -194,3 +195,33 @@ describe('piedDePage : les cinq éléments de toute page imprimée (§ 11.7, § assert.throws(() => piedDePage(soiree(), { ...base, source: 'proposition', proposition: 7, tour: 1 }), ErreurCommande); }); }); + +describe("refusImpression : le refus que rend l'issue d'une impression (§ 11.7, § 13.4)", () => { + test('une impression envoyée, annulée par l’opérateur ou d’issue inconnue ne rend aucun refus', () => { + for (const issue of ['envoye', 'annule', 'inconnue']) assert.equal(refusImpression({ issue, cause: null }), null, issue); + }); + + test('un échec rend IMPRESSION_ECHEC, sa cause et aucun remède ; le texte nomme la cause, ou s’en passe', () => { + const avecCause = refusImpression({ issue: 'echec', cause: 'Imprimante hors ligne' }); + assert.ok(avecCause instanceof ErreurCommande); + assert.equal(avecCause.code, 'IMPRESSION_ECHEC'); + assert.deepEqual(avecCause.details, { cause: 'Imprimante hors ligne', remede: null }); + assert.match(libelle(avecCause.code, avecCause.details), /\(Imprimante hors ligne\)/); + const sansCause = refusImpression({ issue: 'echec', cause: null }); + assert.deepEqual(sansCause.details, { cause: null, remede: null }); + assert.doesNotMatch(libelle(sansCause.code, sansCause.details), /\(|null|undefined/); + }); + + test('une issue hors du contrat de l’imprimante lève TypeError', () => { + for (const issue of [ + { issue: 'imprime', cause: null }, + { issue: 'echec', cause: 3 }, + { issue: 'echec' }, + { issue: 'annule', cause: 'cancelled' }, + null, + undefined, + ]) { + assert.throws(() => refusImpression(issue), TypeError, JSON.stringify(issue)); + } + }); +}); diff --git a/src/impression/imprimante_electron.js b/src/impression/imprimante_electron.js new file mode 100644 index 0000000..1327b43 --- /dev/null +++ b/src/impression/imprimante_electron.js @@ -0,0 +1,58 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'imprimante de la coquille electron, côté page (§ 11.7, § 13.4) : la +// frontière de plateforme « impression » sous la coquille, qui passe par la +// fonction imprimer du pont gtt que le préchargement pose sur la fenêtre. +// La demande ne porte que le papier et l'orientation ; le processus +// principal (electron/impression.js) en compose les options de +// webContents.print — la boîte de dialogue du système, le format de page du +// papier, le paysage — et rend l'issue de son rappel : envoyée, annulée ou +// échouée, la raison de l'échec pour cause. Le papier et l'orientation se +// jugent ici avant le pont, comme sous web, et la réponse du pont à son +// retour : une réponse d'une autre forme est une faute de la coquille. +// src/main.js seul l'importe, et lui passe ce pont, qu'il est seul à lire +// avec le système de fichiers de la coquille ; les épreuves lui passent un +// pont factice. +import { PAPIERS } from '../geometrie/papiers.js'; + +const ORIENTATIONS = new Set(['portrait', 'paysage']); + +// Les issues que la coquille rend ; seule celle d'un échec porte une cause. +const ISSUES = new Set(['envoye', 'annule', 'echec']); + +// Vrai pour une réponse du pont du contrat : une issue de la coquille, une +// cause chaîne pour un échec ou null, null pour les autres issues. +function estReponse(reponse) { + if (reponse === null || typeof reponse !== 'object' || !ISSUES.has(reponse.issue)) return false; + return reponse.cause === null || (reponse.issue === 'echec' && typeof reponse.cause === 'string'); +} + +/** + * L'imprimante de la coquille electron. + * + * @param {{imprimer: (demande: {papier: string, orientation: string}) => Promise}} pont + * le pont gtt que le préchargement expose sur la fenêtre + * @returns {import('./imprimante_web.js').Imprimante} imprimer passe au + * pont {papier, orientation}, eux seuls, une fois, et rend + * {issue, cause} ; une option hors de son domaine — un papier hors + * de PAPIERS, une orientation autre que portrait ou paysage — + * rejette TypeError sans rien appeler ; une réponse du pont d'une + * autre forme rejette TypeError ; un refus du pont se rejette tel + * quel + * @throws {TypeError} un pont sans fonction imprimer + */ +export function creerImprimanteElectron(pont) { + if (typeof pont?.imprimer !== 'function') throw new TypeError('creerImprimanteElectron : pont sans fonction imprimer'); + return { + async imprimer({ papier, orientation } = {}) { + if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) { + throw new TypeError(`imprimer : papier inconnu ${JSON.stringify(papier)}`); + } + if (!ORIENTATIONS.has(orientation)) throw new TypeError(`imprimer : orientation inconnue ${JSON.stringify(orientation)}`); + const reponse = await pont.imprimer({ papier, orientation }); + if (!estReponse(reponse)) throw new TypeError(`imprimer : réponse de la coquille illisible ${JSON.stringify(reponse)}`); + return { issue: reponse.issue, cause: reponse.cause }; + }, + }; +} diff --git a/src/impression/imprimante_electron.test.js b/src/impression/imprimante_electron.test.js new file mode 100644 index 0000000..7a26699 --- /dev/null +++ b/src/impression/imprimante_electron.test.js @@ -0,0 +1,101 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'imprimante de la coquille electron, côté page (§ 13.4, § 11.7) : le +// pont window.gtt.imprimer, que remplace ici un pont factice qui consigne +// chaque demande et rend la réponse qu'on lui donne. Ce que le processus +// principal fait de la demande s'éprouve dans test/coquille.test.js. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { PAPIERS } from '../geometrie/papiers.js'; +import { creerImprimanteElectron } from './imprimante_electron.js'; + +// Un pont qui consigne chaque demande d'impression et rend la réponse +// suivante, ou rejette l'erreur donnée. +function pontFactice(reponses = [], rejet = null) { + const pont = { + demandes: [], + async imprimer(options) { + pont.demandes.push(options); + if (rejet !== null) throw rejet; + return reponses.shift(); + }, + }; + return pont; +} + +describe('imprimante electron (§ 13.4)', () => { + test("imprimer passe au pont le papier et l'orientation, eux seuls, une fois, et rend l'issue du processus principal, pour chaque papier et chaque orientation", async () => { + for (const papier of Object.keys(PAPIERS)) { + for (const orientation of ['portrait', 'paysage']) { + const pont = pontFactice([{ issue: 'envoye', cause: null }]); + const issue = await creerImprimanteElectron(pont).imprimer({ papier, orientation, silent: true }); + assert.deepEqual(issue, { issue: 'envoye', cause: null }, `${papier} ${orientation}`); + assert.deepEqual(pont.demandes, [{ papier, orientation }], `${papier} ${orientation}`); + } + } + }); + + test('les issues annulée et échouée passent telles quelles, cause comprise', async () => { + const reponses = [ + { issue: 'annule', cause: null }, + { issue: 'echec', cause: 'failed' }, + { issue: 'echec', cause: null }, + ]; + const pont = pontFactice(reponses.map((reponse) => ({ ...reponse }))); + const imprimante = creerImprimanteElectron(pont); + for (const attendu of reponses) { + assert.deepEqual(await imprimante.imprimer({ papier: 'A4', orientation: 'paysage' }), attendu); + } + assert.equal(pont.demandes.length, 3); + }); + + test('une option hors domaine rejette TypeError sans appeler le pont', async () => { + const refusees = [ + { papier: 'A3', orientation: 'portrait' }, + { papier: 'toString', orientation: 'portrait' }, + { papier: undefined, orientation: 'portrait' }, + { papier: 'A4', orientation: 'Portrait' }, + { papier: 'A4', orientation: 'landscape' }, + { papier: 'A4' }, + ]; + for (const options of refusees) { + const pont = pontFactice(); + await assert.rejects(creerImprimanteElectron(pont).imprimer(options), TypeError, JSON.stringify(options)); + assert.deepEqual(pont.demandes, [], JSON.stringify(options)); + } + const pont = pontFactice(); + await assert.rejects(creerImprimanteElectron(pont).imprimer(), TypeError); + assert.deepEqual(pont.demandes, []); + }); + + test("une réponse d'une autre forme lève TypeError : issue inconnue de la coquille, cause d'un autre type ou d'une issue qui n'en porte pas, réponse absente", async () => { + const fautives = [ + undefined, + null, + { issue: 'inconnue', cause: null }, + { issue: 'envoye' }, + { issue: 'echec', cause: 3 }, + { issue: 'imprime', cause: null }, + { issue: 'annule', cause: 'cancelled' }, + { issue: 'envoye', cause: 'failed' }, + 'envoye', + ]; + for (const reponse of fautives) { + const pont = pontFactice([reponse]); + await assert.rejects(creerImprimanteElectron(pont).imprimer({ papier: 'A4', orientation: 'portrait' }), TypeError, JSON.stringify(reponse)); + } + }); + + test('un refus du processus principal se rejette tel quel', async () => { + const refus = new Error("Error invoking remote method 'gtt:imprimer': TypeError: impression : option refusée"); + const pont = pontFactice([], refus); + await assert.rejects(creerImprimanteElectron(pont).imprimer({ papier: 'A4', orientation: 'portrait' }), (erreur) => erreur === refus); + }); + + test('un pont sans imprimer se refuse à la construction', () => { + for (const pont of [null, undefined, {}, { imprimer: 'oui' }, { fichiers: {} }]) { + assert.throws(() => creerImprimanteElectron(pont), TypeError, String(pont)); + } + }); +}); diff --git a/src/interface/App.svelte b/src/interface/App.svelte index 849f11c..e6a4694 100644 --- a/src/interface/App.svelte +++ b/src/interface/App.svelte @@ -6,8 +6,8 @@ // l'application — système de fichiers, horloge, exécuteur de génération, // presse-papiers, identifiants, langue, provenance, démonstrations, format // courant, version du produit et sa forme Windows, chargement de la police - // embarquée, et en option le thème de départ — que src/main.js construit - // pour la machine et monterApp pour + // embarquée, imprimante de la plateforme, et en option le thème de + // départ — que src/main.js construit pour la machine et monterApp pour // une épreuve : App ne choisit aucune plateforme, et ne lit la version que // dans ses propriétés, la même au bandeau, au titre, à l'accueil et dans // le bloc « À propos ». @@ -15,11 +15,12 @@ // À son initialisation, App crée une fois chacun la séance, sans racine, // le contrôleur de génération et la façade, et les donne aux composants par // le contexte de Svelte — clés 'seance', 'facade', 'generation', 'ui', 't', - // 'format', 'horloge' et 'relireListe' —, posé de façon synchrone avant que - // la détermination du dossier de travail, asynchrone, ne rende. La façade - // pose la racine de la séance par changerRacine : aussitôt sans question, - // après la réponse sinon. App lit la liste du dossier de travail par la - // façade et la donne à l'accueil, une fois la détermination réglée — sans + // 'format', 'horloge', 'imprimante' et 'relireListe' —, posé de façon + // synchrone avant que la détermination du dossier de travail, asynchrone, + // ne rende. La façade pose la racine de la séance par changerRacine : + // aussitôt sans question, après la réponse sinon. App lit la liste du + // dossier de travail par la façade et la donne à l'accueil, une fois la + // détermination réglée — sans // question au démarrage, après la réponse sinon : pret se résout quand la // détermination est faite, la liste lue quand aucune question n'attend, et // le premier écran — les lignes de la liste, ou la question — rendu. Les @@ -74,6 +75,10 @@ // () => Promise<{regulier: Uint8Array, gras: Uint8Array}> : les octets // de la police que la production de la planche embarque (§ 11.1). polices, + // { imprimer({papier, orientation}) → Promise<{issue, cause}> } : la + // frontière « impression » de la plateforme (§ 13.4), que les + // composants reçoivent par le contexte, sans importer src/impression. + imprimante, themeInitial, } = untrack(() => dependances); @@ -139,6 +144,7 @@ setContext('t', t); setContext('format', format); setContext('horloge', horloge); + setContext('imprimante', imprimante); // Un refus de la séance ou de la façade porte un code, que l'écran // traduit ; toute autre erreur est une faute, qui remonte. diff --git a/src/main.js b/src/main.js index ea9fb53..f6af440 100644 --- a/src/main.js +++ b/src/main.js @@ -3,14 +3,15 @@ // Point d'entrée de la page : le seul module de src/ qui choisit la // plateforme (§ 13.4). Sous la coquille Electron, le préchargement expose -// window.gtt — le système de fichiers du processus principal et la copie -// dans le presse-papiers ; sans lui, la page est la plateforme web, sur -// l'OPFS et le presse-papiers du navigateur. Il construit les dépendances -// d'App — l'horloge du système, l'exécuteur de la génération sur le -// travailleur, les identifiants tirés par crypto.randomUUID, les +// window.gtt — le système de fichiers du processus principal, la copie +// dans le presse-papiers et l'impression ; sans lui, la page est la +// plateforme web, sur l'OPFS, le presse-papiers et la boîte d'impression +// du navigateur. L'imprimante (src/impression), frontière de plateforme +// comme le système de fichiers, ne s'importe que d'ici. Il construit les +// dépendances d'App — l'horloge du système, l'exécuteur de la génération sur +// le travailleur, les identifiants tirés par crypto.randomUUID, les // démonstrations livrées nommées par le catalogue, le chargement de la -// police embarquée — et monte App dans le -// corps du document. Le thème de départ est laissé à App, qui lit la +// police embarquée, l'imprimante — et monte App dans le corps du document. Le thème de départ est laissé à App, qui lit la // préférence du système. // // Il est le seul module qui importe le travailleur de génération, par @@ -36,6 +37,8 @@ import { mount } from 'svelte'; import { creerExecuteurTravailleur } from './application/executeur_travailleur.js'; import { creerHorlogeSysteme } from './application/horloge.js'; import { demonstrationsLivrees } from './demo/livrees.js'; +import { creerImprimanteElectron } from './impression/imprimante_electron.js'; +import { creerImprimanteWeb } from './impression/imprimante_web.js'; import App from './interface/App.svelte'; import Travailleur from './interface/generation/travailleur.js?worker&inline'; import { attendrePolices, chargerPolices } from './polices/chargement.js'; @@ -51,6 +54,7 @@ const fs = sousElectron ? creerFichiersElectron(pont.fichiers) : creerFichiersWe const pressePapiers = sousElectron ? { copier: async (texte) => void (await pont.copier(texte)) } : { copier: (texte) => navigator.clipboard.writeText(texte) }; +const imprimante = sousElectron ? creerImprimanteElectron(pont) : creerImprimanteWeb(window); try { await attendrePolices(document); @@ -75,5 +79,6 @@ mount(App, { produitVersion: VERSION.affichee, versionWindows: VERSION.windows, polices: chargerPolices, + imprimante, }, }); diff --git a/src/stockage/fichiers_electron.js b/src/stockage/fichiers_electron.js index 11f2fea..e6f11ff 100644 --- a/src/stockage/fichiers_electron.js +++ b/src/stockage/fichiers_electron.js @@ -13,7 +13,7 @@ // // L'écriture atomique et le verrou du § 8.8 sont ceux de la coquille. import { ErreurStockage } from './erreurs.js'; -import { exigerDepuis } from './systeme_fichiers.js'; +import { exigerDepuis, exigerOctets } from './systeme_fichiers.js'; /** * Le système de fichiers de la plateforme electron. @@ -46,6 +46,12 @@ export function creerFichiersElectron(pont = window.gtt.fichiers) { typeSupport: (racine) => appeler('typeSupport', racine), lireTexte: (racine, chemin) => appeler('lireTexte', racine, chemin), ecrireAtomique: (racine, chemin, texte) => appeler('ecrireAtomique', racine, chemin, texte), + // La forme des octets se juge ici, avant le pont : une TypeError levée + // dans le processus principal n'en ressortirait que par son message. + ecrireOctetsAtomique: async (racine, chemin, octets) => { + exigerOctets(octets); + return appeler('ecrireOctetsAtomique', racine, chemin, octets); + }, ajouterLigne: (racine, chemin, ligne) => appeler('ajouterLigne', racine, chemin, ligne), lister: (racine, dossier) => appeler('lister', racine, dossier), creerDossier: (racine, chemin) => appeler('creerDossier', racine, chemin), diff --git a/src/stockage/fichiers_web.js b/src/stockage/fichiers_web.js index 354433b..872826d 100644 --- a/src/stockage/fichiers_web.js +++ b/src/stockage/fichiers_web.js @@ -15,8 +15,9 @@ // // La plateforme n'offre ni renommage atomique ni verrou (§ 8.8) et le dit : // renommageAtomique et verrouDisponible sont faux, et l'application -// l'annonce au démarrage. ecrireAtomique et ajouterLigne écrivent par un flux -// de l'OPFS ouvert sur la cible (createWritable) : le contenu n'atteint la +// l'annonce au démarrage. ecrireAtomique, ecrireOctetsAtomique et ajouterLigne +// écrivent par un flux de l'OPFS ouvert sur la cible (createWritable) : le +// contenu n'atteint la // cible qu'à la fermeture du flux ; un échec avant elle abandonne le flux et // retire le fichier que l'appel a créé, si bien que la cible reste telle // qu'avant l'appel. Rien ne garantit ce que devient une fermeture @@ -64,7 +65,7 @@ // le fichier sans dire où ni si l'opérateur y renonce, et la primitive rend // le nom proposé. import { ErreurStockage } from './erreurs.js'; -import { exigerCheminRelatif, exigerDepuis } from './systeme_fichiers.js'; +import { exigerCheminRelatif, exigerDepuis, exigerOctets } from './systeme_fichiers.js'; // Le dossier du produit dans l'OPFS, et la racine documents qui le désigne. const DOSSIER_PRODUIT = 'gestion_table_tournante_libre'; @@ -411,6 +412,18 @@ export function creerFichiersWeb() { }); }, + // Le flux reçoit une copie des octets, prise avant toute attente : + // l'appelant qui réemploie son tableau ne change pas le fichier. + ecrireOctetsAtomique: async (racine, chemin, octets) => { + exigerOctets(octets); + const copie = new Uint8Array(octets); + const loc = localiser(racine, chemin); + await ecrire(loc, async () => { + if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT); + await ecrireParFlux(await dossierDe(loc.parent), loc.nom, false, (flux) => flux.write(copie)); + }); + }, + // Le flux garde le contenu présent et écrit la ligne à sa fin. ajouterLigne: async (racine, chemin, ligne) => { exigerChaine(ligne, 'la ligne'); diff --git a/src/stockage/fichiers_web.navigateur.test.js b/src/stockage/fichiers_web.navigateur.test.js index e2cd881..e8e9dbe 100644 --- a/src/stockage/fichiers_web.navigateur.test.js +++ b/src/stockage/fichiers_web.navigateur.test.js @@ -371,6 +371,27 @@ describe("fichiers web : ce que l'OPFS refuse", () => { expect(await fs.lireTexte(DOCUMENTS, 'etat.json')).toBe('avant'); }); + test("ecrireOctetsAtomique écrit les octets tels quels par un flux ; une panne de l'OPFS lève ECRITURE avec le nom de son exception, laisse la cible intacte et ne laisse aucun fichier neuf", async () => { + const fs = await preparer(); + await fs.ecrireOctetsAtomique(DOCUMENTS, 'planche.pdf', new Uint8Array(40).fill(0x41)); + await fs.ecrireOctetsAtomique(DOCUMENTS, 'planche.pdf', BRUTS); + expect(await octetsOpfs('planche.pdf')).toStrictEqual(BRUTS); + for (const [temps, prototype, methode, cause] of PANNES) { + vi.spyOn(prototype(), methode).mockRejectedValue(new DOMException(temps, cause)); + for (const chemin of ['planche.pdf', 'neuve.pdf']) { + await echoue( + fs.ecrireOctetsAtomique(DOCUMENTS, chemin, new Uint8Array([0x25, 0x50])), + 'ECRITURE', + { chemin, dossier: CHEMIN_DOCUMENTS, cause }, + `${temps} : ${chemin}`, + ); + } + vi.restoreAllMocks(); + expect(await octetsOpfs('planche.pdf'), temps).toStrictEqual(BRUTS); + expect(noms(await fs.lister(DOCUMENTS, '')), temps).toStrictEqual(['planche.pdf']); + } + }); + test('deplacer copie les octets de la source puis la retire ; un échec de la copie ou du retrait laisse la source seule', async () => { const fs = await preparer(); await fs.creerDossier(DOCUMENTS, 'corbeille'); @@ -618,6 +639,8 @@ describe('fichiers web : fautes de programmation', () => { () => fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', null), () => fs.enregistrerSous('', BRUTS), () => fs.enregistrerSous('liste.csv', 'nom;prenom'), + () => fs.ecrireOctetsAtomique(DOCUMENTS, 'planche.pdf', 'texte'), + () => fs.ecrireOctetsAtomique(DOCUMENTS, 'planche.pdf', new ArrayBuffer(2)), ]; for (const faute of fautes) await expect(faute(), String(faute)).rejects.toThrow(TypeError); expect(creer).not.toHaveBeenCalled(); diff --git a/src/stockage/noms.js b/src/stockage/noms.js index 83d5b69..96412bd 100644 --- a/src/stockage/noms.js +++ b/src/stockage/noms.js @@ -2,11 +2,12 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Les noms des fichiers d'un événement (§ 8.6, § 8.7) : leurs suffixes, la -// base que le nom de l'événement donne dans un dossier de travail, et le -// dossier daté de la corbeille. Le module est pur : il ne touche aucun -// fichier, ne lit ni horloge ni aléa, et ne dépend d'aucune langue — la casse -// se compare par toUpperCase ou toLowerCase, jamais par une comparaison -// localisée. +// base que le nom de l'événement donne dans un dossier de travail, le +// dossier daté de la corbeille, et le nom de la planche de badges que +// l'événement produit à côté de ses fichiers (§ 11.6). Le module est pur : +// il ne touche aucun fichier, ne lit ni horloge ni aléa, et ne dépend +// d'aucune langue — la casse se compare par toUpperCase ou toLowerCase, +// jamais par une comparaison localisée. // // Les longueurs se comptent en unités UTF-16, comme MAX_PATH de Windows, et // une coupe ne sépare jamais une paire de substitution. Le chemin complet @@ -15,7 +16,11 @@ // l'événement écrit : un fichier de la corbeille datée, au suffixe le plus // long, que l'écriture atomique prolonge de SUFFIXE_ECRITURE. Une base qui ne // tiendrait que pour l'état laisserait déborder le journal, le fichier -// précédent et leurs fichiers d'écriture. +// 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. +import { PAPIERS } from '../geometrie/papiers.js'; import { ErreurStockage } from './erreurs.js'; // Suffixes des fichiers d'un événement, que précède la base de son nom. @@ -245,6 +250,18 @@ export function deriverBase(nom, { racine, separateur, existantes }) { // chemin du dossier ne retient que les chiffres du jour et de l'heure. const HORODATAGE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})[+-]\d{2}:\d{2}$/; +// Le jour et l'heure d'un horodatage, AAAA-MM-JJ_HH-MM-SS : tels qu'écrits, +// sans le décalage, les deux-points, que Windows refuse dans un nom, devenus +// des traits d'union. TypeError pour une autre forme. +function jourEtHeure(horodatage) { + const lu = typeof horodatage === 'string' ? HORODATAGE.exec(horodatage) : null; + if (lu === null) { + throw new TypeError(`horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(horodatage)}`); + } + const [, jour, heures, minutes, secondes] = lu; + return `${jour}_${heures}-${minutes}-${secondes}`; +} + const PREFIXE_CORBEILLE = `${DOSSIER_CORBEILLE}/`; /** @@ -266,12 +283,7 @@ const PREFIXE_CORBEILLE = `${DOSSIER_CORBEILLE}/`; * à 99 sont pris ; dossier est le chemin relatif du nom sans rang */ export function dossierCorbeille(horodatage, existants) { - const lu = typeof horodatage === 'string' ? HORODATAGE.exec(horodatage) : null; - if (lu === null) { - throw new TypeError(`horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(horodatage)}`); - } - const [, jour, heures, minutes, secondes] = lu; - const nom = `${jour}_${heures}-${minutes}-${secondes}`; + const nom = jourEtHeure(horodatage); const pris = new Set( Array.from(existants, (entree) => entree.startsWith(PREFIXE_CORBEILLE) ? entree.slice(PREFIXE_CORBEILLE.length) : entree, @@ -288,3 +300,57 @@ export function dossierCorbeille(horodatage, existants) { } return `${PREFIXE_CORBEILLE}${candidat}`; } + +// Les impositions de la planche (§ 11.6), telles que le nom du fichier les +// écrit. +const IMPOSITIONS = Object.freeze(['feuille', 'pile']); + +// Une réédition compte au plus REEDITION_MAX badges, quatre chiffres : le +// plus long suffixe de la planche, « .badges-9999__feuille_.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'; + +/** + * Nom du fichier de la planche de badges (§ 11.6), posé à la racine du + * dossier de travail : .badges[-]___.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. + * + * @param {string} base celle de l'événement, que rend deriverBase + * @param {Object} options + * @param {import('../geometrie/papiers.js').Papier} options.papier + * @param {'feuille'|'pile'} options.imposition + * @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 + * @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 + * @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)}`); + } + 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`; +} diff --git a/src/stockage/noms.test.js b/src/stockage/noms.test.js index b82b0d3..f52e49f 100644 --- a/src/stockage/noms.test.js +++ b/src/stockage/noms.test.js @@ -6,8 +6,9 @@ // écrit, la dérivation de la base d'un nom — caractères invisibles et // demi-paires isolées, noms réservés, caractères que Windows refuse, composition // en NFC, collisions comparées par la majuscule, borne du chemin complet — et -// le dossier daté de la corbeille, jusqu'à son dernier rang. Chaque épreuve de -// dérivation se joue sur les deux dossiers de travail possibles du § 8.6. +// le dossier daté de la corbeille, jusqu'à son dernier rang, et le nom du +// fichier de la planche de badges (§ 11.6). Chaque épreuve de dérivation se +// joue sur les deux dossiers de travail possibles du § 8.6. // // La borne s'éprouve contre un oracle écrit ici : chaque chemin qu'un // événement écrit se recompose en entier, de la racine au suffixe, avec des @@ -17,6 +18,7 @@ // en clair : ce qu'on ne voit pas ne se relit pas. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; +import { PAPIERS } from '../geometrie/papiers.js'; import { ErreurStockage } from './erreurs.js'; import { DOSSIER_CORBEILLE, @@ -26,6 +28,7 @@ import { deriverBase, dossierCorbeille, longueurRelativeMax, + nomPlanche, } from './noms.js'; // Les deux dossiers de travail possibles (§ 8.6) : le dossier du produit dans @@ -992,7 +995,7 @@ function sansSourcesVariables(fonction) { } describe('déterminisme et pureté (§ 14.7)', () => { - test('longueurRelativeMax, deriverBase et dossierCorbeille ne lisent ni horloge, ni aléa, ni comparaison localisée', () => { + test('longueurRelativeMax, deriverBase, dossierCorbeille et nomPlanche ne lisent ni horloge, ni aléa, ni comparaison localisée', () => { // Le filet est éprouvé d'abord : chaque source lève pendant qu'il est tendu. for (const lecture of [ () => Math.random(), @@ -1015,6 +1018,8 @@ describe('déterminisme et pureté (§ 14.7)', () => { derive(lettres(400), DOSSIERS[1], []), derive('Con.soirée', DOSSIERS[0], ['con_.soirée']), 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 }), ]; assert.deepEqual(sansSourcesVariables(rendre), rendre()); }); @@ -1028,3 +1033,104 @@ describe('déterminisme et pureté (§ 14.7)', () => { assert.deepEqual([...existantes], ['gala', 'GALA (2)']); }); }); + +describe('nomPlanche : le nom du fichier de la planche (§ 11.6, § 8.6)', () => { + const BASE = 'Soir\u{E9}e des Lucioles'; + const HORODATAGE = '2031-05-17T20:15:07-04:00'; + // Une réédition de k badges, sous la forme du modèle de la planche. + const reeditionDe = (k) => ({ ids: Array.from({ length: k }, (_, i) => i + 1) }); + + test('les quatre combinaisons papier × imposition : la base, « .badges », le papier, l’imposition, la date et l’heure du placement sans décalage, puis « .pdf »', () => { + const cas = [ + ['A4', 'feuille', `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`], + ['A4', 'pile', `${BASE}.badges_A4_pile_2031-05-17_20-15-07.pdf`], + ['Lettre', 'feuille', `${BASE}.badges_Lettre_feuille_2031-05-17_20-15-07.pdf`], + ['Lettre', 'pile', `${BASE}.badges_Lettre_pile_2031-05-17_20-15-07.pdf`], + ]; + assert.deepEqual(cas.map(([papier]) => papier).filter((p, i, t) => t.indexOf(p) === i), Object.keys(PAPIERS)); + for (const [papier, imposition, attendu] of cas) { + assert.equal(nomPlanche(BASE, { papier, imposition, horodatage: HORODATAGE, reedition: null }), attendu); + } + }); + + test('une réédition de k badges porte « - » après « .badges » ; sans réédition, rien', () => { + const nom = (reedition) => nomPlanche(BASE, { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, reedition }); + assert.equal(nom({ ids: [3, 8, 12] }), `${BASE}.badges-3_A4_feuille_2031-05-17_20-15-07.pdf`); + assert.equal(nom(reeditionDe(1)), `${BASE}.badges-1_A4_feuille_2031-05-17_20-15-07.pdf`); + assert.equal(nom(reeditionDe(9999)), `${BASE}.badges-9999_A4_feuille_2031-05-17_20-15-07.pdf`); + assert.equal(nom(null), `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`); + assert.equal(nom(undefined), `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`); + }); + + test('un horodatage inconnu donne « _horodatage-inconnu » ; un décalage, quel qu’il soit, ne change pas le nom ; aucun « : »', () => { + assert.equal( + nomPlanche(BASE, { papier: 'Lettre', imposition: 'pile', horodatage: null, reedition: null }), + `${BASE}.badges_Lettre_pile_horodatage-inconnu.pdf`, + ); + for (const horodatage of ['2031-05-17T20:15:07+05:30', '2031-05-17T20:15:07+00:00', HORODATAGE]) { + const nom = nomPlanche(BASE, { papier: 'A4', imposition: 'feuille', horodatage, reedition: reeditionDe(2) }); + assert.equal(nom, `${BASE}.badges-2_A4_feuille_2031-05-17_20-15-07.pdf`, horodatage); + assert.ok(!nom.includes(':'), nom); + } + }); + + test('même placement, même papier, même imposition : même nom ; un autre placement, un autre nom', () => { + const options = { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, reedition: null }; + assert.equal(nomPlanche(BASE, options), nomPlanche(BASE, { ...options })); + assert.notEqual(nomPlanche(BASE, options), nomPlanche(BASE, { ...options, horodatage: '2031-05-17T20:15:08-04:00' })); + }); + + test('le plus long suffixe, 51 unités, plus le suffixe d’écriture tient sous la partie fixe de longueurRelativeMax : la planche d’une base à la borne ne dépasse pas 259 unités', () => { + const suffixes = []; + for (const papier of Object.keys(PAPIERS)) { + for (const imposition of ['feuille', 'pile']) { + for (const horodatage of [HORODATAGE, null]) { + for (const reedition of [null, reeditionDe(9), reeditionDe(9999)]) { + suffixes.push(nomPlanche('b', { papier, imposition, horodatage, reedition }).slice(1)); + } + } + } + } + assert.equal(suffixes.length, 24); + const plusLong = suffixes.reduce((a, b) => (b.length > a.length ? b : a)); + assert.equal(plusLong, '.badges-9999_Lettre_feuille_2031-05-17_20-15-07.pdf'); + assert.equal(plusLong.length, 51); + assert.ok( + plusLong.length + SUFFIXE_ECRITURE.length <= longueurRelativeMax(''), + `${plusLong.length} + ${SUFFIXE_ECRITURE.length} > ${longueurRelativeMax('')}`, + ); + // Recomposé en entier, à côté des fichiers de l'événement, sur une base + // que deriverBase pousse à la borne. + for (const dossier of DOSSIERS) { + const base = deriverBase(lettres(400), { ...dossier, existantes: [] }); + assert.equal(base.length, longueurMaxDeBase(dossier)); + const nom = nomPlanche(base, { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: reeditionDe(9999) }); + const chemin = `${dossier.racine}${dossier.separateur}${nom}.ecriture`; + assert.ok(chemin.length <= LONGUEUR_CHEMIN_MAX, `${dossier.racine} : ${chemin.length} unités`); + } + }); + + 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 = [ + [TypeError, '', valides], + [TypeError, 42, valides], + [TypeError, BASE, { ...valides, papier: 'A3' }], + [TypeError, BASE, { ...valides, papier: 'toString' }], + [TypeError, BASE, { ...valides, papier: undefined }], + [TypeError, BASE, { ...valides, imposition: 'massicot' }], + [TypeError, BASE, { ...valides, imposition: undefined }], + [TypeError, BASE, { ...valides, horodatage: '2031-05-17T20:15:07Z' }], + [TypeError, BASE, { ...valides, horodatage: '2031-05-17 20:15:07-04:00' }], + [TypeError, BASE, { ...valides, horodatage: undefined }], + [TypeError, BASE, { ...valides, reedition: 3 }], + [TypeError, BASE, { ...valides, reedition: { ids: 'abc' } }], + [RangeError, BASE, { ...valides, reedition: { ids: [] } }], + [RangeError, BASE, { ...valides, reedition: reeditionDe(10000) }], + ]; + for (const [type, base, options] of fautes) { + assert.throws(() => nomPlanche(base, options), type, `${JSON.stringify(base)} ${JSON.stringify(options).slice(0, 120)}`); + } + assert.throws(() => nomPlanche(BASE), TypeError); + }); +}); diff --git a/src/stockage/systeme_fichiers.js b/src/stockage/systeme_fichiers.js index 0e64a2d..ba3beef 100644 --- a/src/stockage/systeme_fichiers.js +++ b/src/stockage/systeme_fichiers.js @@ -114,6 +114,15 @@ import { estHorodatage } from './journal.js'; * dossier qui porte la cible doit exister : un dossier absent lève * ECRITURE, car un dossier de travail disparu — un support retiré — * ne se recrée pas en silence + * @property {(r: Racine, chemin: string, octets: Uint8Array) => Promise} ecrireOctetsAtomique + * les octets tels quels, avec les garanties d'ecrireAtomique : même + * fichier .ecriture, même renommage et mêmes réessais, même cible + * intacte après un échec, même ECRITURE pour un dossier absent ; là + * où renommageAtomique est faux, l'écriture va droit sur la cible. + * Le fichier ne garde aucun lien au tableau reçu, que l'appelant peut + * réemployer. Des octets d'une autre forme qu'un Uint8Array lèvent + * TypeError sans rien écrire (exigerOctets). C'est l'écriture d'un + * fichier qui n'est pas un texte : la planche de badges (§ 11.6) * @property {(r: Racine, chemin: string, ligne: string) => Promise} ajouterLigne * ajoute la ligne puis une fin de ligne LF après le dernier octet du * fichier, sans rien insérer avant, vidé sur le disque ; crée le @@ -193,3 +202,17 @@ export function exigerCheminRelatif(chemin) { export function exigerDepuis(depuis) { if (!estHorodatage(depuis)) throw new TypeError(`instant de prise : horodatage attendu, reçu ${JSON.stringify(depuis)}`); } + +/** + * Lève TypeError quand octets n'est pas un Uint8Array — un Buffer de Node en + * est un — : chaque implémentation d'ecrireOctetsAtomique l'appelle avant de + * rien écrire, et celle de la coquille avant de passer le pont. + * + * @param {unknown} octets + * @throws {TypeError} + */ +export function exigerOctets(octets) { + if (!(octets instanceof Uint8Array)) { + throw new TypeError(`octets : Uint8Array attendu, reçu ${Object.prototype.toString.call(octets)}`); + } +} diff --git a/src/stockage/systeme_fichiers.test.js b/src/stockage/systeme_fichiers.test.js index 567ad9a..6f556a8 100644 --- a/src/stockage/systeme_fichiers.test.js +++ b/src/stockage/systeme_fichiers.test.js @@ -5,13 +5,19 @@ // exigerCheminRelatif, que partagent l'implémentation web et celle // d'épreuve, rend les segments d'un chemin admis, et refuse par // CHEMIN_REFUSE tout chemin absolu, remontant, ou que Windows lirait -// autrement. Le reste du module est l'interface en JSDoc, que la suite de -// contrat test/contrat_fichiers.js éprouve sur chaque implémentation. Les -// noms d'épreuve sont inventés. +// autrement ; exigerOctets, que partagent les trois implémentations côté +// page, n'admet qu'un Uint8Array. Le reste du module est l'interface en +// JSDoc, que la suite de contrat test/contrat_fichiers.js éprouve sur chaque +// implémentation. L'atomicité de l'écriture des octets, qu'une panne seule +// montre, s'éprouve ici sur l'implémentation d'épreuve, la seule qui sache +// refuser un renommage ; celle de la coquille, dans +// test/fichiers_electron.test.js, celle de la plateforme web, dans +// fichiers_web.navigateur.test.js. Les noms d'épreuve sont inventés. import assert from 'node:assert/strict'; +import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { describe, test } from '../../test/lanceur.js'; import { ErreurStockage } from './erreurs.js'; -import { exigerCheminRelatif } from './systeme_fichiers.js'; +import { exigerCheminRelatif, exigerOctets } from './systeme_fichiers.js'; // Rend l'erreur que lève la fonction ; échoue quand elle n'en lève pas. function erreurDe(fonction) { @@ -82,3 +88,82 @@ describe('exigerCheminRelatif', () => { } }); }); + +describe('exigerOctets', () => { + test('admet un Uint8Array, vide compris, et ses sous-classes ; refuse tout le reste par TypeError', () => { + for (const octets of [new Uint8Array(0), new Uint8Array([0x25, 0xff]), Buffer.from('pdf')]) exigerOctets(octets); + for (const fautifs of ['texte', [1, 2], new ArrayBuffer(2), new Uint16Array(2), new Int8Array(2), null, undefined, { length: 0 }]) { + assert.throws(() => exigerOctets(fautifs), TypeError, Object.prototype.toString.call(fautifs)); + } + }); +}); + +describe("ecrireOctetsAtomique sur le système d'épreuve : l'atomicité (§ 8.8)", () => { + const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre'; + const DOC = { id: 'documents', chemin: DOCUMENTS }; + const ANCIENS = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x0a, 0xe2]); + const NOUVEAUX = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x32, 0x0a, 0xff]); + + // Un système d'épreuve dont la racine documents porte planche.pdf. + function preparer(options = {}) { + const fs = creerFichiersSimules(options); + fs.deposerOctets('documents', 'planche.pdf', ANCIENS); + return fs; + } + + // Rend l'erreur de la promesse ; échoue quand elle se résout. + async function rejet(promesse) { + try { + await promesse; + } catch (erreur) { + return erreur; + } + return assert.fail('la promesse se résout'); + } + + test("un renommage refusé laisse l'ancien fichier, retire le fichier d'écriture et lève ECRITURE en nommant fichier et dossier", async () => { + const fs = preparer(); + fs.pannes.renommage('documents'); + const erreur = await rejet(fs.ecrireOctetsAtomique(DOC, 'planche.pdf', NOUVEAUX)); + assert.ok(erreur instanceof ErreurStockage, String(erreur)); + assert.deepEqual([erreur.code, erreur.details], ['ECRITURE', { chemin: 'planche.pdf', dossier: DOCUMENTS, cause: 'EBUSY' }]); + assert.deepEqual(fs.octets('documents', 'planche.pdf'), ANCIENS); + assert.equal(fs.octets('documents', 'planche.pdf.ecriture'), null); + assert.deepEqual((await fs.lister(DOC, '')).map(({ nom }) => nom), ['planche.pdf']); + }); + + test("le renommage se réessaie comme celui d'ecrireAtomique : dix refus et l'écriture passe, onze et elle échoue", async () => { + const fs = preparer(); + fs.pannes.renommage('documents', 10, 'EPERM'); + await fs.ecrireOctetsAtomique(DOC, 'planche.pdf', NOUVEAUX); + assert.deepEqual(fs.octets('documents', 'planche.pdf'), NOUVEAUX); + fs.pannes.renommage('documents', 11, 'EPERM'); + const erreur = await rejet(fs.ecrireOctetsAtomique(DOC, 'planche.pdf', ANCIENS)); + assert.equal(erreur.code, 'ECRITURE'); + assert.deepEqual(fs.octets('documents', 'planche.pdf'), NOUVEAUX); + }); + + test("un support qui refuse l'écriture lève ECRITURE avec sa cause, sans rien changer ; sans renommage atomique, l'écriture va droit sur la cible", async () => { + const refuse = preparer(); + refuse.pannes.ecriture('documents', 'EROFS'); + const erreur = await rejet(refuse.ecrireOctetsAtomique(DOC, 'planche.pdf', NOUVEAUX)); + assert.deepEqual([erreur.code, erreur.details.cause], ['ECRITURE', 'EROFS']); + assert.deepEqual(refuse.octets('documents', 'planche.pdf'), ANCIENS); + + const direct = preparer({ renommageAtomique: false }); + direct.pannes.renommage('documents'); + await direct.ecrireOctetsAtomique(DOC, 'planche.pdf', NOUVEAUX); + assert.deepEqual(direct.octets('documents', 'planche.pdf'), NOUVEAUX); + }); + + test("l'écriture compte parmi les écritures du processus : le compteur la relève, et une coupure l'arrête comme les autres", async () => { + const fs = preparer(); + await fs.ecrireOctetsAtomique(DOC, 'planche.pdf', NOUVEAUX); + assert.deepEqual(fs.compteurs, { ecritures: 1, lectures: 0 }); + fs.pannes.couperApres(1); + await fs.ecrireOctetsAtomique(DOC, 'planche.pdf', ANCIENS); + const erreur = await rejet(fs.ecrireOctetsAtomique(DOC, 'planche.pdf', NOUVEAUX)); + assert.equal(erreur.name, 'PanneSimulee'); + assert.deepEqual(fs.octets('documents', 'planche.pdf'), ANCIENS); + }); +}); diff --git a/test/arborescence.test.js b/test/arborescence.test.js index ce39b80..d3459d6 100644 --- a/test/arborescence.test.js +++ b/test/arborescence.test.js @@ -3740,6 +3740,107 @@ describe('arborescence : une seule composition des démonstrations (§ 15)', () }); }); +// Le corps de mount(App, { props: { … } }) d'un module, '' sans lui. +const proprietesDApp = (code) => /\bmount\(App, \{[\s\S]*?\bprops: \{([\s\S]*?)\n\s*\},\n/.exec(code)?.[1] ?? ''; +// L'objet que rend monterApp, '' sans lui : de « return { » à « }; » au +// premier niveau du corps de la fonction. +const renduDuMontage = (code) => /\n {2}return \{\n([\s\S]*?)\n {2}\};\n\}/.exec(code)?.[1] ?? ''; +// Le corps de la déstructuration des dépendances d'App, '' sans lui. +const dependancesDApp = (code) => /\bconst \{\n([\s\S]*?)\n\s*\} = untrack\(\(\) => dependances\);/.exec(code)?.[1] ?? ''; +const NOMMEE_SEULE = (nom) => new RegExp(`^\\s*${nom},$`, 'm'); + +// Le câblage de l'imprimante (§ 13.4) : par fichier, chaque pièce qu'il +// porte, commentaires ôtés — [ce qui manque, vrai quand la pièce est là]. +// src/main.js construit l'imprimante de la plateforme, celle d'electron sur +// le pont window.gtt sous la coquille, celle du navigateur sur la fenêtre +// sinon, et la passe à App ; le montage des épreuves prend l'imprimante +// d'épreuve par défaut, la passe à App et la rend, sans rien importer de +// src/impression ; App la reçoit dans ses dépendances et la donne aux +// composants sous la clé de contexte 'imprimante'. Un motif d'import +// s'écrit « fro[m] », que la garde des dépendances ne prend pas pour un +// chargement. +const CABLAGE_DE_L_IMPRIMANTE = new Map([ + [ + 'src/main.js', + [ + ['import de creerImprimanteElectron absent', (code) => /^import \{ creerImprimanteElectron \} fro[m] '\.\/impression\/imprimante_electron\.js';$/m.test(code)], + ['import de creerImprimanteWeb absent', (code) => /^import \{ creerImprimanteWeb \} fro[m] '\.\/impression\/imprimante_web\.js';$/m.test(code)], + ['pont window.gtt absent', (code) => /^const pont = window\.gtt;$/m.test(code)], + [ + 'imprimante de la plateforme absente', + (code) => /^const imprimante = sousElectron \? creerImprimanteElectron\(pont\) : creerImprimanteWeb\(window\);$/m.test(code), + ], + ['imprimante non passée à App', (code) => NOMMEE_SEULE('imprimante').test(proprietesDApp(code))], + ], + ], + [ + 'test/navigateur/montage.js', + [ + ['import de creerImprimanteSimulee absent', (code) => /^import \{ creerImprimanteSimulee \} fro[m] '\.\.\/imprimante_simulee\.js';$/m.test(code)], + ["imprimante d'épreuve par défaut absente", (code) => /^\s*imprimante = creerImprimanteSimulee\(\),$/m.test(code)], + ['imprimante non passée à App', (code) => NOMMEE_SEULE('imprimante').test(proprietesDApp(code))], + ['imprimante non rendue', (code) => NOMMEE_SEULE('imprimante').test(renduDuMontage(code))], + ['src/impression importé', (code) => !/src\/impression\//.test(code)], + ], + ], + [ + 'src/interface/App.svelte', + [ + ['imprimante absente des dépendances', (code) => NOMMEE_SEULE('imprimante').test(dependancesDApp(code))], + ["contexte 'imprimante' non posé", (code) => /^\s*setContext\('imprimante', imprimante\);$/m.test(code)], + ], + ], +]); + +// Relevé du câblage de l'imprimante : « fichier : ce qui manque », dans +// l'ordre de la table puis de ses pièces ; « fichier : fichier absent » pour +// un fichier que l'arbre n'a pas. +function releverCablageImprimante(racine, cablage = CABLAGE_DE_L_IMPRIMANTE) { + const releves = []; + for (const [chemin, pieces] of cablage) { + if (!existsSync(join(racine, chemin))) { + releves.push(`${chemin} : fichier absent`); + continue; + } + const code = texteSansCommentaires(chemin, readFileSync(join(racine, chemin), 'utf8')); + for (const [manque, presente] of pieces) if (!presente(code)) releves.push(`${chemin} : ${manque}`); + } + return releves; +} + +describe("arborescence : le câblage de l'imprimante (§ 13.4)", () => { + test("src/main.js construit l'imprimante de la plateforme, le montage des épreuves l'imprimante d'épreuve, chacun la passe à App, qui la donne aux composants sous la clé 'imprimante'", () => { + assert.deepEqual(releverCablageImprimante(RACINE), []); + }); + + test("la garde relève chaque pièce absente, fichier par fichier, et un fichier absent ; un commentaire ne compte pas", () => { + const releves = avecArbre( + { + 'src/main.js': + "import { creerImprimanteWeb } from './impression/imprimante_web.js';\n" + + 'const pont = window.gtt;\nconst imprimante = creerImprimanteWeb(window);\n' + + 'mount(App, {\n target: document.body,\n props: {\n fs,\n },\n});\nconst autre = { imprimante,\n};\n', + 'test/navigateur/montage.js': + "import { creerImprimanteSimulee } from '../imprimante_simulee.js';\nimport { x } from '../../src/impression/imprimante_web.js';\n" + + 'export function monterApp({\n imprimante = creerImprimanteSimulee(),\n} = {}) {\n' + + ' const app = mount(App, {\n target,\n props: {\n imprimante,\n },\n });\n return {\n app,\n };\n}\n', + 'src/interface/App.svelte': + "\n", + }, + (racine) => [...releverCablageImprimante(racine), ...releverCablageImprimante(racine, new Map([['src/absent.js', []]]))], + ); + assert.deepEqual(releves, [ + 'src/main.js : import de creerImprimanteElectron absent', + 'src/main.js : imprimante de la plateforme absente', + 'src/main.js : imprimante non passée à App', + 'test/navigateur/montage.js : imprimante non rendue', + 'test/navigateur/montage.js : src/impression importé', + "src/interface/App.svelte : contexte 'imprimante' non posé", + 'src/absent.js : fichier absent', + ]); + }); +}); + describe('arborescence : un seul chemin de code pour les réservations (§ 5.2)', () => { test('dans src/moteur, seuls configuration.js et diagnostic.js lisent les réservations', () => { assert.deepEqual(releverLecteursDesReservations(RACINE), []); diff --git a/test/contrat_fichiers.js b/test/contrat_fichiers.js index 42cd83f..4676f1e 100644 --- a/test/contrat_fichiers.js +++ b/test/contrat_fichiers.js @@ -33,6 +33,7 @@ const PRIMITIVES = [ 'typeSupport', 'lireTexte', 'ecrireAtomique', + 'ecrireOctetsAtomique', 'ajouterLigne', 'lister', 'creerDossier', @@ -54,6 +55,18 @@ const DEPUIS_LOINTAIN = '2031-05-18T06:12:00+05:30'; // décalage, en temps universel, un instant en millisecondes, une date seule. const DEPUIS_FAUTIFS = [undefined, null, '', '2031-05-17 19:42:07', '2031-05-17T19:42:07Z', 1_950_000_000_000, '2031-05-17']; +// Octets d'un fichier binaire : l'en-tête d'un PDF, dont la ligne de +// commentaire porte des octets hors UTF-8, puis un nul, un 0xFF et un CRLF. +// Les octets d'un texte en UTF-8, accent et fin de ligne CRLF compris. +const OCTETS_BINAIRES = Object.freeze([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, 0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a, 0x00, 0xff, 0x0d, 0x0a, +]); +const TEXTE_EN_OCTETS = 'Soir\u{E9}e\r\n'; + +// Des octets d'une autre forme qu'un Uint8Array : un texte, un tableau de +// nombres, un tampon nu, rien. +const OCTETS_FAUTIFS = [() => 'texte', () => [0x25, 0x50], () => new ArrayBuffer(2), () => null, () => undefined]; + // Chemins qu'aucune implémentation n'accepte : remontant, absolu sous Unix, // absolu sous Windows, remontant après un détour. const CHEMINS_REFUSES = ['../x', '/x', 'C:\\x', 'a/../../x']; @@ -65,6 +78,7 @@ const CHEMINS_REFUSES = ['../x', '/x', 'C:\\x', 'a/../../x']; const APPELS_A_CHEMIN = [ ['lireTexte', (fs, racine, chemin) => fs.lireTexte(racine, chemin)], ['ecrireAtomique', (fs, racine, chemin) => fs.ecrireAtomique(racine, chemin, 'texte')], + ['ecrireOctetsAtomique', (fs, racine, chemin) => fs.ecrireOctetsAtomique(racine, chemin, new Uint8Array([0x25]))], ['ajouterLigne', (fs, racine, chemin) => fs.ajouterLigne(racine, chemin, 'ligne')], ['lister', (fs, racine, chemin) => fs.lister(racine, chemin)], ['creerDossier', (fs, racine, chemin) => fs.creerDossier(racine, chemin)], @@ -119,7 +133,7 @@ export function eprouverContrat(nom, fabrique, { describe, test, egal, vrai, rej } describe(`contrat du système de fichiers : ${nom}`, () => { - test("l'interface porte sa nature, ses deux garanties et ses dix-sept primitives, et décrit ses emplacements et ses racines", async () => { + test("l'interface porte sa nature, ses deux garanties et ses dix-huit primitives, et décrit ses emplacements et ses racines", async () => { const { fs, racine } = await fabrique(); vrai(['electron', 'web', 'epreuve'].includes(fs.nature), `nature ${fs.nature}`); egal(typeof fs.renommageAtomique, 'boolean', 'renommageAtomique'); @@ -158,6 +172,47 @@ export function eprouverContrat(nom, fabrique, { describe, test, egal, vrai, rej egal(noms(await fs.lister(racine, '')), ['etat.json']); }); + test("ecrireOctetsAtomique écrit les octets tels quels : relus, ce sont les mêmes, hors UTF-8 compris ; il remplace le contenu entier, ne garde pas le tableau reçu et ne laisse aucun fichier .ecriture", async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireOctetsAtomique(racine, 'planche.pdf', new Uint8Array(64).fill(0x41)); + const octets = new Uint8Array(OCTETS_BINAIRES); + await fs.ecrireOctetsAtomique(racine, 'planche.pdf', octets); + // L'appelant réemploie son tableau : le fichier n'en dépend plus. + octets.fill(0); + // Des octets hors UTF-8 se relisent par le refus de lireTexte, qui en + // porte une copie. + const { details } = await echoue(fs.lireTexte(racine, 'planche.pdf'), 'LECTURE'); + egal(details.cause, 'UTF8_INVALIDE'); + egal(details.chemin, 'planche.pdf'); + egal([...details.octets], [...OCTETS_BINAIRES]); + egal( + (await fs.lister(racine, '')).map(({ nom: nomEntree, type, taille }) => ({ nom: nomEntree, type, taille })), + [{ nom: 'planche.pdf', type: 'fichier', taille: OCTETS_BINAIRES.length }], + ); + + // Des octets d'UTF-8 se relisent en texte, tels qu'écrits. + await fs.ecrireOctetsAtomique(racine, 'liste.csv', new TextEncoder().encode(TEXTE_EN_OCTETS)); + egal(await fs.lireTexte(racine, 'liste.csv'), TEXTE_EN_OCTETS); + egal(noms(await fs.lister(racine, '')), ['liste.csv', 'planche.pdf']); + }); + + test('ecrireOctetsAtomique dans un dossier absent lève ECRITURE, sans créer le dossier ni toucher aux autres fichiers', async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireAtomique(racine, 'ici.txt', 'ici'); + await refuseEcriture(fs.ecrireOctetsAtomique(racine, 'disparu/planche.pdf', new Uint8Array(OCTETS_BINAIRES)), 'disparu/planche.pdf'); + egal(noms(await fs.lister(racine, '')), ['ici.txt']); + egal(await fs.lireTexte(racine, 'ici.txt'), 'ici'); + }); + + test("ecrireOctetsAtomique refuse des octets d'une autre forme qu'un Uint8Array : TypeError, et rien ne s'écrit", async () => { + const { fs, racine } = await fabrique(); + for (const fautifs of OCTETS_FAUTIFS) { + const raison = await rejette(fs.ecrireOctetsAtomique(racine, 'planche.pdf', fautifs()), String(fautifs)); + vrai(raison instanceof TypeError, `${String(fautifs)} : ${raison}`); + } + egal(await fs.lister(racine, ''), []); + }); + test('ajouterLigne ajoute la ligne et une fin de ligne LF après le dernier octet, et crée le fichier absent', async () => { const { fs, racine } = await fabrique(); await fs.ajouterLigne(racine, 'journal.jsonl', '{"type":"journal"}'); diff --git a/test/coquille.test.js b/test/coquille.test.js index 3add23f..c79e80b 100644 --- a/test/coquille.test.js +++ b/test/coquille.test.js @@ -4,18 +4,20 @@ // La coquille Electron (§ 13.1) : la fenêtre qu'ouvre le processus principal, // la page qu'elle charge, le profil de Chromium hors d'AppData (§ 8.6), le // pont étroit et nommé que le préchargement expose — une fonction par -// primitive du système de fichiers, un canal par fonction, et la copie dans -// le presse-papiers —, ce que le processus principal y répond : emplacements, -// racines, dialogues, explorateur, presse-papiers ; et les verrous que la -// page a pris, effacés à la sortie (§ 8.8). La page elle-même, index.html, -// charge le collecteur d'erreurs avant son module d'entrée (§ 19.2). +// primitive du système de fichiers, un canal par fonction, la copie dans +// le presse-papiers et l'impression —, ce que le processus principal y +// répond : emplacements, racines, dialogues, explorateur, presse-papiers, +// impression (§ 11.7, § 13.4) ; et les verrous que la page a pris, effacés +// à la sortie (§ 8.8). La page elle-même, index.html, charge le collecteur +// d'erreurs avant son module d'entrée (§ 19.2). // // electron/main.js s'exécute sous Node, dans un processus à part, contre // test/electron_factice.js, qui consigne ce que la coquille demande à // Electron puis rend son journal en JSON. Ce qui s'éprouve ici est ce que la // coquille demande ; qu'Electron l'applique relève d'une épreuve dans un vrai // Electron (make essai_demarrage). Les primitives elles-mêmes s'éprouvent -// dans test/fichiers_electron.test.js. +// dans test/fichiers_electron.test.js ; le gestionnaire d'impression +// (electron/impression.js) s'éprouve ici, sur un contenu de fenêtre factice. import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -23,6 +25,8 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { PRIMITIVES } from '../electron/fichiers.js'; +import { CANAL_IMPRESSION, FORMATS_DE_PAGE, imprimer, optionsImpression, servirImpression } from '../electron/impression.js'; +import { PAPIERS } from '../src/geometrie/papiers.js'; import { describe, test } from './lanceur.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); @@ -32,6 +36,7 @@ const PRECHARGEMENT = join(RACINE, 'electron', 'preload.cjs'); const NOM_PRODUIT = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8')).productName; const CANAUX = PRIMITIVES.map((primitive) => `gtt:fichiers:${primitive}`); const CANAL_COPIE = 'gtt:copier'; +const CANAL_IMPRIMER = 'gtt:imprimer'; // Dossier que publie le lanceur portable, et LOCALAPPDATA, dans le scénario // qui les donne. Aucun des deux n'est lu ni écrit. @@ -171,13 +176,14 @@ describe('coquille : processus principal', () => { ]); }); - test('un canal gtt:fichiers: par primitive, puis celui du presse-papiers, et aucun autre, chacun posé avant le chargement de la page', () => { + test("un canal gtt:fichiers: par primitive, puis celui du presse-papiers, puis celui de l'impression, et aucun autre, chacun posé avant le chargement de la page", () => { const { canaux, sequence } = journalDeLaCoquille(); - assert.deepEqual(canaux, [...CANAUX, CANAL_COPIE]); - assert.equal(CANAUX.length, 17); + assert.equal(CANAL_IMPRESSION, CANAL_IMPRIMER); + assert.deepEqual(canaux, [...CANAUX, CANAL_COPIE, CANAL_IMPRIMER]); + assert.equal(CANAUX.length, 18); const charge = sequence.indexOf('loadFile'); assert.notEqual(charge, -1); - for (const canal of [...CANAUX, CANAL_COPIE]) { + for (const canal of [...CANAUX, CANAL_COPIE, CANAL_IMPRIMER]) { const pose = sequence.indexOf(`ipcMain.handle:${canal}`); assert.ok(pose !== -1 && pose < charge, `${canal} : ${sequence.join(', ')}`); } @@ -236,12 +242,13 @@ describe('coquille : processus principal', () => { }); describe('coquille : préchargement', () => { - test("n'expose que window.gtt.fichiers, une fonction par primitive, et gtt.copier, une fonction ; ne requiert qu'electron", () => { + test("n'expose que window.gtt.fichiers, une fonction par primitive, gtt.copier et gtt.imprimer, deux fonctions ; ne requiert qu'electron", () => { const { prechargement } = journalDeLaCoquille(); assert.equal(prechargement.erreur, undefined, prechargement.erreur); assert.deepEqual(prechargement.requis, ['electron']); - assert.deepEqual(prechargement.exposes, [{ cle: 'gtt', membres: ['copier', 'fichiers'] }]); + assert.deepEqual(prechargement.exposes, [{ cle: 'gtt', membres: ['copier', 'fichiers', 'imprimer'] }]); assert.equal(prechargement.copie.type, 'function'); + assert.equal(prechargement.impression.type, 'function'); assert.deepEqual(prechargement.fichiers, [...PRIMITIVES]); assert.deepEqual( prechargement.appels.map(({ membre, type }) => ({ membre, type })), @@ -255,12 +262,14 @@ describe('coquille : préchargement', () => { journal.prechargement.appels.map(({ membre, canaux }) => ({ membre, canaux })), PRIMITIVES.map((membre) => ({ membre, canaux: [`gtt:fichiers:${membre}`] })), ); - // Après le pont : la copie, puis les deux verrous réels que prend la page. + // Après le pont : la copie, les deux verrous réels que prend la page, + // puis ses cinq demandes d'impression. assert.deepEqual(journal.invocations, [ ...CANAUX, CANAL_COPIE, 'gtt:fichiers:verrouiller', 'gtt:fichiers:verrouiller', + ...Array(5).fill(CANAL_IMPRIMER), ]); // Une racine que le processus principal ne connaît pas est refusée par // lui, en réponse et non en erreur. @@ -269,6 +278,7 @@ describe('coquille : préchargement', () => { ['typeSupport', ''], ['lireTexte', 'soiree.gtt.json'], ['ecrireAtomique', 'soiree.gtt.json'], + ['ecrireOctetsAtomique', 'soiree.badges.pdf'], ['ajouterLigne', 'soiree.gtt-journal.jsonl'], ['lister', ''], ['creerDossier', 'corbeille'], @@ -350,6 +360,34 @@ describe('coquille : préchargement', () => { assert.deepEqual(pressePapiers, [copie.texte]); }); + test("gtt.imprimer invoque le canal gtt:imprimer, une fois par appel : le processus principal imprime la fenêtre qui le demande, avec le papier et l'orientation reçus, et rend l'issue du rappel", () => { + const { prechargement, impressions, sequence } = journalDeLaCoquille(); + const { appels } = prechargement.impression; + assert.deepEqual( + appels.map(({ canaux }) => canaux), + appels.map(() => [CANAL_IMPRIMER]), + ); + assert.deepEqual( + appels.slice(0, 3).map(({ reponse, erreur }) => ({ reponse, erreur })), + [ + { reponse: { issue: 'envoye', cause: null }, erreur: undefined }, + { reponse: { issue: 'annule', cause: null }, erreur: undefined }, + { reponse: { issue: 'echec', cause: 'failed' }, erreur: undefined }, + ], + ); + assert.deepEqual(impressions, [ + { silent: false, printBackground: true, pageSize: 'A4', landscape: false }, + { silent: false, printBackground: true, pageSize: 'Letter', landscape: true }, + { silent: false, printBackground: true, pageSize: 'A4', landscape: true }, + ]); + // Une option de print brute, un papier inconnu : refusés, sans imprimer. + for (const { options, reponse, erreur } of appels.slice(3)) { + assert.equal(reponse, undefined, JSON.stringify(options)); + assert.equal(typeof erreur, 'string', JSON.stringify(options)); + } + assert.equal(sequence.filter((appel) => appel === 'webContents.print').length, 3); + }); + test("l'explorateur s'ouvre sur la racine que la page désigne", () => { const journal = journalDeLaCoquille(); assert.deepEqual(journal.explorateur, [join(journal.chemins.documents, NOM_PRODUIT)]); @@ -366,3 +404,98 @@ describe('coquille : la page', () => { assert.ok(existsSync(join(RACINE, 'public', 'collecteur.js')), 'public/collecteur.js absent'); }); }); + +describe('coquille : le gestionnaire d’impression (§ 11.7, § 13.4)', () => { + // Le contenu d'une fenêtre qui consigne les options de chaque print et + // répond à son rappel par (succes, raison), ou lève l'erreur donnée. + function contenuFactice(succes = true, raison = '', leve = null) { + const contenu = { + options: [], + print(options, rappel) { + contenu.options.push(options); + if (leve !== null) throw leve; + queueMicrotask(() => rappel(succes, raison)); + }, + }; + return contenu; + } + + test('un format de page par papier, que la coquille recopie des clés de PAPIERS : A4 reste A4, Lettre devient Letter', () => { + assert.deepEqual(FORMATS_DE_PAGE, { A4: 'A4', Lettre: 'Letter' }); + assert.deepEqual(Object.keys(FORMATS_DE_PAGE), Object.keys(PAPIERS)); + assert.ok(Object.isFrozen(FORMATS_DE_PAGE)); + }); + + test("le papier et l'orientation deviennent les seules options de print : boîte de dialogue montrée, fonds imprimés, format de page du papier, paysage pour paysage", () => { + for (const papier of Object.keys(PAPIERS)) { + for (const [orientation, landscape] of [['portrait', false], ['paysage', true]]) { + assert.deepEqual(optionsImpression({ papier, orientation }), { + silent: false, + printBackground: true, + pageSize: FORMATS_DE_PAGE[papier], + landscape, + }); + } + } + }); + + test('toute autre option est refusée par TypeError, sans appeler print : une option de print brute, un papier ou une orientation hors domaine, une demande qui n’est pas un objet', async () => { + const refusees = [ + { papier: 'A4', orientation: 'portrait', silent: true }, + { papier: 'A4', orientation: 'portrait', deviceName: 'imprimante-voisine' }, + { papier: 'A4', orientation: 'portrait', pageSize: 'A3' }, + { papier: 'A3', orientation: 'portrait' }, + { papier: 'Letter', orientation: 'portrait' }, + { papier: 'toString', orientation: 'portrait' }, + { papier: 'A4', orientation: 'landscape' }, + { papier: 'A4' }, + {}, + null, + 'A4', + ['A4', 'portrait'], + undefined, + ]; + for (const demande of refusees) { + const contenu = contenuFactice(); + await assert.rejects(imprimer(contenu, demande), TypeError, JSON.stringify(demande)); + assert.deepEqual(contenu.options, [], JSON.stringify(demande)); + } + }); + + test('le rappel donne l’issue : succès → envoyé ; « cancelled » → annulé ; une autre raison → échec, qu’elle nomme ; un print qui lève → échec sans cause, le message gardé pour la console', async () => { + const cas = [ + [[true, ''], { issue: 'envoye', cause: null }], + [[false, 'cancelled'], { issue: 'annule', cause: null }], + [[false, 'failed'], { issue: 'echec', cause: 'failed' }], + [[false, 'Invalid printer settings'], { issue: 'echec', cause: 'Invalid printer settings' }], + [[false, ''], { issue: 'echec', cause: null }], + [[false, undefined], { issue: 'echec', cause: null }], + ]; + for (const [[succes, raison], attendu] of cas) { + const contenu = contenuFactice(succes, raison); + assert.deepEqual(await imprimer(contenu, { papier: 'Lettre', orientation: 'paysage' }), attendu, String(raison)); + assert.equal(contenu.options.length, 1); + } + const leve = contenuFactice(true, '', new Error('Object has been destroyed')); + const console0 = console.error; + const journal = []; + console.error = (...valeurs) => journal.push(valeurs); + try { + assert.deepEqual(await imprimer(leve, { papier: 'A4', orientation: 'portrait' }), { issue: 'echec', cause: null }); + } finally { + console.error = console0; + } + assert.equal(journal.length, 1); + assert.ok(String(journal[0].at(-1)?.message ?? journal[0].at(-1)).includes('Object has been destroyed')); + }); + + test('servirImpression pose le seul canal gtt:imprimer ; son gestionnaire imprime l’expéditeur de la demande', async () => { + const poses = []; + servirImpression((canal, gestionnaire) => poses.push({ canal, gestionnaire })); + assert.deepEqual(poses.map(({ canal }) => canal), [CANAL_IMPRIMER]); + const contenu = contenuFactice(false, 'cancelled'); + const issue = await poses[0].gestionnaire({ sender: contenu }, { papier: 'A4', orientation: 'portrait' }); + assert.deepEqual(issue, { issue: 'annule', cause: null }); + assert.deepEqual(contenu.options, [{ silent: false, printBackground: true, pageSize: 'A4', landscape: false }]); + }); +}); diff --git a/test/electron_factice.js b/test/electron_factice.js index c2ee6a1..fbe4775 100644 --- a/test/electron_factice.js +++ b/test/electron_factice.js @@ -14,7 +14,9 @@ // gestionnaires posés par le processus principal ; puis gtt.copier, avec un // texte d'épreuve, et gtt.fichiers.verrouiller sur la racine documents, deux // verrous réels que la page ne rend pas ; le contenu du second est récrit au -// nom d'une autre séance, comme après une reprise. Il joue ensuite la sortie +// nom d'une autre séance, comme après une reprise ; puis gtt.imprimer, une +// fois par jeu d'options d'IMPRESSIONS_DE_LA_PAGE, que le processus principal +// porte au print de la fenêtre qui l'a demandé. Il joue ensuite la sortie // de l'application — before-quit, will-quit, quit —, relève la présence de // ces deux verrous et de celui d'une autre séance, autre.gtt.verrou, avant et // après la sortie, puis imprime le journal en JSON sur la sortie standard. @@ -37,7 +39,10 @@ // un AppData qui y vivent ; app.getName, le nom de produit de package.json, // comme Electron. Les dialogues rendent une annulation ; l'explorateur // consigne le dossier qu'on lui demande d'ouvrir, et réussit ; le -// presse-papiers consigne chaque texte qu'il reçoit. +// presse-papiers consigne chaque texte qu'il reçoit ; le print d'une fenêtre +// consigne ses options et répond à son rappel, dans une microtâche, par +// l'issue suivante d'ISSUES_DE_PRINT. Un appel de la page parvient à son +// gestionnaire avec, pour expéditeur, le contenu de la fenêtre. import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { registerHooks } from 'node:module'; import { tmpdir } from 'node:os'; @@ -67,6 +72,23 @@ const VERROU_AUTRE = 'autre.gtt.verrou'; // L'instant de prise que la page passe avec chaque verrou, celui de son // horloge. const DEPUIS_PAGE = '2026-10-06T19:00:00-04:00'; +// Ce que la page demande d'imprimer, dans l'ordre : trois jeux admis, puis +// un jeu qui porte une option de print brute et un papier inconnu, que le +// processus principal refuse sans imprimer. +const IMPRESSIONS_DE_LA_PAGE = [ + { papier: 'A4', orientation: 'portrait' }, + { papier: 'Lettre', orientation: 'paysage' }, + { papier: 'A4', orientation: 'paysage' }, + { papier: 'A4', orientation: 'portrait', silent: true }, + { papier: 'A3', orientation: 'portrait' }, +]; +// Ce que le print rend à son rappel, (succès, raison de l'échec), par appel : +// envoyé, annulé par l'opérateur, échoué. +const ISSUES_DE_PRINT = [ + [true, ''], + [false, 'cancelled'], + [false, 'failed'], +]; const NOM_PRODUIT = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).productName; // Ce que rend app.getPath, par nom. @@ -96,6 +118,7 @@ const ARGUMENTS_DE_LA_PAGE = { verrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice', DEPUIS_PAGE], deverrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice'], ouvrirDansExplorateur: (racines) => [racines?.documents], + ecrireOctetsAtomique: () => [INCONNUE, 'soiree.badges.pdf', new Uint8Array([0x25, 0x50, 0x44, 0x46])], enregistrerSous: () => ['liste.csv', new Uint8Array([0x6e, 0x6f, 0x6d])], }; @@ -124,6 +147,8 @@ const journal = { explorateur: [], // Texte de chaque clipboard.writeText. pressePapiers: [], + // Options de chaque webContents.print. + impressions: [], // Réponse du gestionnaire d'ouverture de chaque fenêtre à une adresse // externe. ouvertures: [], @@ -131,7 +156,8 @@ const journal = { navigations: [], // Ce que fait le préchargement : modules requis, objets exposés, membres // de gtt.fichiers, et pour chacun les canaux qu'il invoque et sa réponse ; - // puis l'appel de gtt.copier et ceux des deux verrous de la page, de même. + // puis l'appel de gtt.copier et ceux des deux verrous de la page, de même ; + // enfin le type de gtt.imprimer et chacun de ses appels. prechargement: null, // Présence du verrou de la page, de celui qu'une autre séance a repris et // de celui de l'autre séance, avant puis après la sortie de l'application ; @@ -190,7 +216,7 @@ export class BrowserWindow { if (!pret) throw new Error('Cannot create BrowserWindow before app is ready'); journal.sequence.push('new BrowserWindow'); journal.fenetres.push(options); - const page = { ouverture: null, ecouteurs: [] }; + const page = { ouverture: null, ecouteurs: [], contenu: null }; pageDeFenetre.push(page); this.webContents = { setWindowOpenHandler(gestionnaire) { @@ -199,7 +225,14 @@ export class BrowserWindow { on(evenement, ecouteur) { page.ecouteurs.push({ evenement, ecouteur }); }, + print(options, rappel) { + journal.sequence.push('webContents.print'); + journal.impressions.push(options); + const [succes, raison] = ISSUES_DE_PRINT[journal.impressions.length - 1] ?? [true, '']; + queueMicrotask(() => rappel(succes, raison)); + }, }; + page.contenu = this.webContents; } loadFile(chemin) { @@ -276,7 +309,7 @@ function electronDuRendu(exposes) { journal.invocations.push(canal); const gestionnaire = gestionnaires.get(canal); if (gestionnaire === undefined) throw new Error(`No handler registered for '${canal}'`); - return gestionnaire({ sender: null }, ...arguments_); + return gestionnaire({ sender: pageDeFenetre[0]?.contenu ?? null }, ...arguments_); }, }, }; @@ -351,6 +384,12 @@ async function jouerLePrechargement() { const contenu = JSON.parse(readFileSync(chemin, 'utf8')); writeFileSync(chemin, `${JSON.stringify({ ...contenu, seance: SEANCE_REPRISE })}\n`); } + const impression = { type: typeof gtt?.imprimer, appels: [] }; + if (typeof gtt?.imprimer === 'function') { + for (const options of IMPRESSIONS_DE_LA_PAGE) { + impression.appels.push({ options, ...(await appelerUne(gtt.imprimer, [options])) }); + } + } return { requis, exposes: exposes.map(({ cle, api }) => ({ cle, membres: Object.keys(api).sort() })), @@ -359,6 +398,7 @@ async function jouerLePrechargement() { copie, verrou, repris, + impression, documents: documents?.chemin ?? null, }; } diff --git a/test/fichiers_electron.test.js b/test/fichiers_electron.test.js index a97bb16..4c94f12 100644 --- a/test/fichiers_electron.test.js +++ b/test/fichiers_electron.test.js @@ -307,6 +307,12 @@ function creerDisque() { const { noeud } = resoudre(chemin, true, 'contenu'); return noeud?.type === 'fichier' ? noeud.octets.toString('utf8') : null; }, + // Les octets d'un fichier, copiés en Uint8Array, liens suivis ; null pour + // un absent ou un dossier. + octets(chemin) { + const { noeud } = resoudre(chemin, true, 'octets'); + return noeud?.type === 'fichier' ? new Uint8Array(noeud.octets) : null; + }, // Vrai quand le chemin désigne un nœud, liens des dossiers parents // suivis, celui du dernier segment non. existe(chemin) { @@ -460,6 +466,7 @@ const ecriture = (chemin, dossier, cause) => ({ chemin, dossier, cause }); const APPELS_A_CHEMIN = [ ['lireTexte', (fichiers, racine, chemin) => fichiers.lireTexte(racine, chemin)], ['ecrireAtomique', (fichiers, racine, chemin) => fichiers.ecrireAtomique(racine, chemin, 'x')], + ['ecrireOctetsAtomique', (fichiers, racine, chemin) => fichiers.ecrireOctetsAtomique(racine, chemin, new Uint8Array([0x25]))], ['ajouterLigne', (fichiers, racine, chemin) => fichiers.ajouterLigne(racine, chemin, 'x')], ['lister', (fichiers, racine, chemin) => fichiers.lister(racine, chemin)], ['creerDossier', (fichiers, racine, chemin) => fichiers.creerDossier(racine, chemin)], @@ -598,6 +605,87 @@ describe('coquille, fichiers : écriture atomique (§ 8.8)', () => { }); }); +describe('coquille, fichiers : écriture atomique des octets (§ 8.8)', () => { + const CIBLE = `${DOCUMENTS}/planche.pdf`; + const TEMPORAIRE = `${CIBLE}${SUFFIXE_ECRITURE}`; + // L'en-tête d'un PDF, octets hors UTF-8 compris, et ceux d'une planche + // précédente. + const OCTETS = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, 0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a, 0x00, 0xff]); + const ANCIENS = 'ancienne planche'; + + test("écrit les octets tels quels dans .ecriture, le vide sur le disque, le ferme, puis le renomme par-dessus la cible, qui n'est jamais ouverte", async () => { + const { disque, fichiers, pauses } = preparer(); + disque.deposer(CIBLE, ANCIENS); + await fichiers.ecrireOctetsAtomique(DOC, 'planche.pdf', OCTETS); + assert.deepEqual(disque.octets(CIBLE), OCTETS); + assert.equal(disque.existe(TEMPORAIRE), false); + assert.deepEqual( + ecritures(disque).filter((ligne) => !ligne.startsWith('unlink ')), + [ + `open ${TEMPORAIRE} wx`, + `write ${TEMPORAIRE}`, + `sync ${TEMPORAIRE}`, + `close ${TEMPORAIRE}`, + `rename ${TEMPORAIRE} ${CIBLE}`, + ], + ); + assert.deepEqual(disque.appels.filter((ligne) => ligne.startsWith(`open ${CIBLE} `)), []); + assert.deepEqual(pauses, []); + }); + + test('un renommage refusé sur une cause passagère se réessaie comme celui du texte : onze refus lèvent ECRITURE, la cible intacte, le temporaire retiré', async () => { + const passe = preparer(); + passe.disque.deposer(CIBLE, ANCIENS); + passe.disque.panne('rename', 'EBUSY', { fois: 2 }); + await passe.fichiers.ecrireOctetsAtomique(DOC, 'planche.pdf', OCTETS); + assert.deepEqual(passe.disque.octets(CIBLE), OCTETS); + assert.deepEqual(passe.pauses, [50, 100]); + + const echoue = preparer(); + echoue.disque.deposer(CIBLE, ANCIENS); + echoue.disque.panne('rename', 'EBUSY', { fois: 11 }); + await refuse(echoue.fichiers.ecrireOctetsAtomique(DOC, 'planche.pdf', OCTETS), 'ECRITURE', ecriture('planche.pdf', DOCUMENTS, 'EBUSY')); + assert.equal(echoue.disque.contenu(CIBLE), ANCIENS); + assert.equal(echoue.disque.existe(TEMPORAIRE), false); + assert.equal(echoue.disque.appels.filter((ligne) => ligne.startsWith('rename ')).length, 11); + }); + + test("un temporaire qui ne s'écrit, ne se vide ou ne se ferme pas est retiré sans renommage, et la cible reste intacte", async () => { + for (const [fonction, cause] of [['open', 'EROFS'], ['write', 'ENOSPC'], ['sync', 'EIO'], ['close', 'EIO']]) { + const { disque, fichiers } = preparer(); + disque.deposer(CIBLE, ANCIENS); + disque.panne(fonction, cause, { chemin: TEMPORAIRE }); + await refuse(fichiers.ecrireOctetsAtomique(DOC, 'planche.pdf', OCTETS), 'ECRITURE', ecriture('planche.pdf', DOCUMENTS, cause), fonction); + assert.equal(disque.contenu(CIBLE), ANCIENS, fonction); + assert.equal(disque.existe(TEMPORAIRE), false, fonction); + assert.deepEqual(disque.appels.filter((ligne) => ligne.startsWith('rename ')), [], fonction); + } + }); + + test("le dossier de la cible doit exister, et une cible qui est un dossier n'est pas remplacée", async () => { + const { disque, fichiers } = preparer(); + await refuse( + fichiers.ecrireOctetsAtomique(DOC, 'disparu/planche.pdf', OCTETS), + 'ECRITURE', + ecriture('disparu/planche.pdf', `${DOCUMENTS}/disparu`, 'ENOENT'), + ); + assert.equal(disque.existe(`${DOCUMENTS}/disparu`), false); + disque.deposerDossier(`${DOCUMENTS}/planches`); + const avant = disque.appels.length; + await refuse(fichiers.ecrireOctetsAtomique(DOC, 'planches', OCTETS), 'ECRITURE', ecriture('planches', DOCUMENTS, 'EISDIR')); + assert.deepEqual(ecritures({ appels: disque.appels.slice(avant) }), []); + assert.deepEqual(disque.noms(DOCUMENTS), ['planches']); + }); + + test("des octets d'une autre forme qu'un Uint8Array lèvent TypeError, sans toucher au disque", async () => { + const { disque, fichiers } = preparer(); + for (const fautifs of ['%PDF', [0x25], new ArrayBuffer(1), null]) { + await assert.rejects(fichiers.ecrireOctetsAtomique(DOC, 'planche.pdf', fautifs), TypeError, String(fautifs)); + } + assert.deepEqual(disque.appels, []); + }); +}); + describe("coquille, fichiers : l'ajout en fin", () => { const JOURNAL = `${DOCUMENTS}/soiree.gtt-journal.jsonl`; @@ -1630,7 +1718,8 @@ describe('coquille, fichiers : le service IPC', () => { canaux.push(canal); }); assert.deepEqual(canaux, PRIMITIVES.map((primitive) => `gtt:fichiers:${primitive}`)); - assert.equal(PRIMITIVES.length, 17); + assert.equal(PRIMITIVES.length, 18); + assert.equal(PRIMITIVES.indexOf('ecrireOctetsAtomique'), PRIMITIVES.indexOf('ecrireAtomique') + 1); assert.ok(Object.isFrozen(PRIMITIVES)); }); @@ -1750,7 +1839,7 @@ describe('page : src/stockage/fichiers_electron.js', () => { return { pont, appels }; } - test('nature electron, renommage atomique et verrou ; les dix-sept primitives, dans l\u{2019}ordre de la coquille', () => { + test('nature electron, renommage atomique et verrou ; les dix-huit primitives, dans l\u{2019}ordre de la coquille', () => { const fs = creerFichiersElectron(pontFactice([]).pont); assert.deepEqual( { nature: fs.nature, renommageAtomique: fs.renommageAtomique, verrouDisponible: fs.verrouDisponible }, @@ -1772,6 +1861,7 @@ describe('page : src/stockage/fichiers_electron.js', () => { ['typeSupport', [DOC]], ['lireTexte', [DOC, 'a.txt']], ['ecrireAtomique', [DOC, 'a.txt', 'texte']], + ['ecrireOctetsAtomique', [DOC, 'a.pdf', octets]], ['ajouterLigne', [DOC, 'a.jsonl', 'ligne']], ['lister', [DOC, '']], ['creerDossier', [DOC, 'corbeille']], @@ -1801,6 +1891,16 @@ describe('page : src/stockage/fichiers_electron.js', () => { assert.deepEqual(appels, []); }); + test("ecrireOctetsAtomique refuse, avant le pont, des octets d'une autre forme qu'un Uint8Array : TypeError", async () => { + const { pont, appels } = pontFactice([]); + const fs = creerFichiersElectron(pont); + for (const fautifs of ['%PDF', [0x25], new ArrayBuffer(1), undefined]) { + const erreur = await rejette(fs.ecrireOctetsAtomique(DOC, 'a.pdf', fautifs)); + assert.ok(erreur instanceof TypeError, `${String(fautifs)} : ${erreur}`); + } + assert.deepEqual(appels, []); + }); + test("une réponse { ok: false } devient l'ErreurStockage de ce code et de ces détails ; une réponse d'une autre forme, une TypeError", async () => { const { pont } = pontFactice([ { ok: false, code: 'ECRITURE', details: { chemin: 'a.txt', dossier: DOCUMENTS, cause: 'EBUSY' } }, @@ -1824,12 +1924,16 @@ describe('page : src/stockage/fichiers_electron.js', () => { test("à travers le vrai préchargement : chaque primitive de la page appelle son canal, et un refus de la coquille revient en ErreurStockage", async () => { const { disque, fichiers } = preparer(); const { gtt, invoques } = parLePrechargement(fichiers); - assert.deepEqual(Object.keys(gtt), ['fichiers', 'copier']); + assert.deepEqual(Object.keys(gtt), ['fichiers', 'copier', 'imprimer']); assert.equal(typeof gtt.copier, 'function'); + assert.equal(typeof gtt.imprimer, 'function'); assert.deepEqual(Object.keys(gtt.fichiers), [...PRIMITIVES]); assert.ok(Object.values(gtt.fichiers).every((fonction) => typeof fonction === 'function')); const fs = creerFichiersElectron(gtt.fichiers); + const planche = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x0a, 0xe2, 0xe3, 0x00, 0xff]); + await fs.ecrireOctetsAtomique(DOC, 'soiree.badges.pdf', planche); + assert.deepEqual(disque.octets(`${DOCUMENTS}/soiree.badges.pdf`), planche); await fs.ecrireAtomique(DOC, 'soiree.gtt.json', '{"entete":{}}\n'); assert.equal(await fs.lireTexte(DOC, 'soiree.gtt.json'), '{"entete":{}}\n'); assert.equal(disque.contenu(`${DOCUMENTS}/soiree.gtt.json`), '{"entete":{}}\n'); @@ -1840,6 +1944,7 @@ describe('page : src/stockage/fichiers_electron.js', () => { assert.ok(!(faute instanceof ErreurStockage), String(faute)); assert.match(faute.message, /gtt:fichiers:ecrireAtomique/); assert.deepEqual(invoques, [ + 'gtt:fichiers:ecrireOctetsAtomique', 'gtt:fichiers:ecrireAtomique', 'gtt:fichiers:lireTexte', 'gtt:fichiers:deplacer', diff --git a/test/fichiers_simules.js b/test/fichiers_simules.js index 369f5cb..07c7ab3 100644 --- a/test/fichiers_simules.js +++ b/test/fichiers_simules.js @@ -23,7 +23,7 @@ // ainsi le dossier de travail qu'aucun code n'a créé, ou qui a disparu, comme // le verrait la coquille. import { ErreurStockage } from '../src/stockage/erreurs.js'; -import { exigerCheminRelatif, exigerDepuis } from '../src/stockage/systeme_fichiers.js'; +import { exigerCheminRelatif, exigerDepuis, exigerOctets } from '../src/stockage/systeme_fichiers.js'; /** * Levée par chaque primitive d'une instance dont le processus est coupé @@ -55,7 +55,8 @@ const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1); const PAS_HORLOGE = 1000; // La taille d'un fichier compte les octets de son texte en UTF-8. Un fichier -// porte un texte, ou les octets que deposerOctets a posés tels quels : ceux-ci +// porte un texte, ou des octets tels quels — ceux qu'écrit +// ecrireOctetsAtomique, ceux que deposerOctets pose — : ceux-ci // se décodent strictement, comme la coquille et la plateforme web les // décodent, la marque d'ordre d'octets gardée dans le texte. const UTF8 = new TextEncoder(); @@ -162,8 +163,8 @@ const estContenuVerrou = (contenu) => * - pid, hote : ceux du processus, qu'écrit son verrou ; les pid d'un disque * se suivent, 1, 2, 3…, dans l'ordre de création des instances ; * - pannes.renommage(racineId, n = Infinity, cause = 'EBUSY') : les n - * prochains renommages de la racine sont refusés — chaque essai de - * l'écriture atomique, qui en fait onze, et deplacer ; + * prochains renommages de la racine sont refusés — chaque essai d'une + * écriture atomique, texte ou octets, qui en fait onze, et deplacer ; * - pannes.ecriture(racineId, cause = 'EROFS') : dès lors, toute primitive * d'écriture de la racine lève ECRITURE, et sa sonde rend la cause ; * - pannes.couperApres(n) : après n primitives d'écriture réussies — @@ -171,8 +172,9 @@ const estContenuVerrou = (contenu) => * est mort, la table des processus du disque le dit à ses verrous, et * l'on rouvre par une nouvelle instance sur le même disque ; * - compteurs.ecritures, compteurs.lectures : les appels des primitives - * d'écriture (ecrireAtomique, ajouterLigne, creerDossier, deplacer, - * supprimer, verrouiller, deverrouiller, sonder) et de lecture (lireTexte, + * d'écriture (ecrireAtomique, ecrireOctetsAtomique, ajouterLigne, + * creerDossier, deplacer, supprimer, verrouiller, deverrouiller, sonder) + * et de lecture (lireTexte, * lister), refusés compris ; les autres ne comptent pas ; * - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un * fichier, dossiers parents compris, sans compter ni avancer l'horloge ; @@ -319,6 +321,34 @@ export function creerFichiersSimules({ disque.set(cible.cle, { ...noeud, nom: cible.nom, parent: cible.cleParent }); } + // L'écriture atomique d'un contenu, texte ou octets déjà copiés, sous ce + // chemin : .ecriture, puis le renommage par-dessus la cible, réessayé + // comme celui de la coquille ; tous les essais refusés retirent le fichier + // d'écriture et lèvent ECRITURE, la cible intacte. Sans renommage atomique, + // le contenu va droit sur la cible. + function remplacer(racine, chemin, contenu) { + const cible = localiser(racine, chemin); + exigerInscriptible(cible); + exigerDossierParent(cible); + exigerPasDossier(cible); + if (!renommageAtomique) { + poserFichier(cible, contenu, avancer()); + return; + } + const ecriture = localiser(racine, chemin + SUFFIXE_ECRITURE); + poserFichier(ecriture, contenu, avancer()); + let cause = null; + for (let essai = 0; essai <= REESSAIS_RENOMMAGE; essai += 1) { + cause = renommageRefuse(cible.racine); + if (cause === null) { + renommer(ecriture, cible); + return; + } + } + disque.delete(ecriture.cle); + throw refus(cible, cause); + } + // Cause du refus du prochain renommage de la racine, ou null ; chaque refus // en consomme un. function renommageRefuse(racineId) { @@ -434,26 +464,13 @@ export function creerFichiersSimules({ ecrireAtomique: primitive('ecritures', (racine, chemin, texte) => { exigerChaine(texte, 'le texte'); - const cible = localiser(racine, chemin); - exigerInscriptible(cible); - exigerDossierParent(cible); - exigerPasDossier(cible); - if (!renommageAtomique) { - poserFichier(cible, texte, avancer()); - return; - } - const ecriture = localiser(racine, chemin + SUFFIXE_ECRITURE); - poserFichier(ecriture, texte, avancer()); - let cause = null; - for (let essai = 0; essai <= REESSAIS_RENOMMAGE; essai += 1) { - cause = renommageRefuse(cible.racine); - if (cause === null) { - renommer(ecriture, cible); - return; - } - } - disque.delete(ecriture.cle); - throw refus(cible, cause); + remplacer(racine, chemin, texte); + }), + + // Le fichier reçoit une copie des octets : l'appelant garde son tableau. + ecrireOctetsAtomique: primitive('ecritures', (racine, chemin, octets) => { + exigerOctets(octets); + remplacer(racine, chemin, new Uint8Array(octets)); }), ajouterLigne: primitive('ecritures', (racine, chemin, ligne) => { diff --git a/test/imprimante_simulee.js b/test/imprimante_simulee.js new file mode 100644 index 0000000..06dce70 --- /dev/null +++ b/test/imprimante_simulee.js @@ -0,0 +1,52 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'imprimante d'épreuve (§ 13.4) : la troisième implémentation de la +// frontière « impression », qui n'imprime rien. Elle juge le papier et +// l'orientation comme les deux imprimantes réelles, relève chaque demande +// admise et rend l'issue qu'on lui règle à sa création. Le montage des +// épreuves du navigateur la passe à App par défaut ; une épreuve y lit ce +// que l'aperçu a demandé d'imprimer. +import { PAPIERS } from '../src/geometrie/papiers.js'; + +const ORIENTATIONS = new Set(['portrait', 'paysage']); + +// Les issues d'une impression ; seules celles d'un échec portent une cause. +const ISSUES = new Set(['envoye', 'annule', 'echec', 'inconnue']); + +/** + * Une imprimante d'épreuve. + * + * @param {{issue?: 'envoye'|'annule'|'echec'|'inconnue', cause?: string|null}} [reglage] + * l'issue que rend chaque impression, envoyée par défaut, et sa + * cause, null par défaut, une chaîne pour un échec seulement + * @returns {{imprimer: (options: {papier: string, orientation: 'portrait'|'paysage'}) + * => Promise<{issue: string, cause: string|null}>, + * appels: () => Array<{papier: string, orientation: string}>}} + * l'interface Imprimante de src/impression, et le relevé : + * imprimer rejette TypeError pour un papier hors de PAPIERS ou une + * orientation autre que portrait ou paysage, sans rien relever ; + * appels() rend une copie des demandes admises, dans l'ordre, + * papier et orientation seuls + * @throws {TypeError} un réglage hors du contrat + */ +export function creerImprimanteSimulee(reglage = {}) { + if (reglage === null || typeof reglage !== 'object') throw new TypeError("creerImprimanteSimulee : le réglage n'est pas un objet"); + const { issue = 'envoye', cause = null } = reglage; + if (!ISSUES.has(issue)) throw new TypeError(`creerImprimanteSimulee : issue inconnue ${JSON.stringify(issue)}`); + if (!(cause === null || (issue === 'echec' && typeof cause === 'string'))) { + throw new TypeError(`creerImprimanteSimulee : cause ${JSON.stringify(cause)} pour l'issue ${issue}`); + } + const demandes = []; + return { + async imprimer({ papier, orientation } = {}) { + if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) { + throw new TypeError(`imprimer : papier inconnu ${JSON.stringify(papier)}`); + } + if (!ORIENTATIONS.has(orientation)) throw new TypeError(`imprimer : orientation inconnue ${JSON.stringify(orientation)}`); + demandes.push({ papier, orientation }); + return { issue, cause }; + }, + appels: () => demandes.map((demande) => ({ ...demande })), + }; +} diff --git a/test/imprimante_simulee.test.js b/test/imprimante_simulee.test.js new file mode 100644 index 0000000..b35760a --- /dev/null +++ b/test/imprimante_simulee.test.js @@ -0,0 +1,74 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'imprimante d'épreuve (§ 13.4) : la troisième implémentation de la +// frontière « impression », qui relève ses appels et rend l'issue qu'on lui +// règle. Son câblage — src/main.js, le montage des épreuves, App — se garde +// dans test/arborescence.test.js, que leurs motifs de surveillance relancent. +import assert from 'node:assert/strict'; +import { PAPIERS } from '../src/geometrie/papiers.js'; +import { creerImprimanteSimulee } from './imprimante_simulee.js'; +import { describe, test } from './lanceur.js'; + +describe("imprimante d'épreuve", () => { + test('par défaut, chaque impression est envoyée, sans cause, et chaque appel se relève avec ses options, dans l’ordre', async () => { + const imprimante = creerImprimanteSimulee(); + const demandes = []; + for (const papier of Object.keys(PAPIERS)) { + for (const orientation of ['portrait', 'paysage']) { + demandes.push({ papier, orientation }); + assert.deepEqual(await imprimante.imprimer({ papier, orientation }), { issue: 'envoye', cause: null }); + } + } + assert.deepEqual(imprimante.appels(), demandes); + }); + + test("l'issue et la cause réglées se rendent à chaque appel : annulée, échouée et sa cause, inconnue", async () => { + for (const reglage of [ + { issue: 'annule', cause: null }, + { issue: 'echec', cause: 'failed' }, + { issue: 'inconnue', cause: null }, + ]) { + const imprimante = creerImprimanteSimulee(reglage); + assert.deepEqual(await imprimante.imprimer({ papier: 'A4', orientation: 'portrait' }), reglage); + assert.deepEqual(await imprimante.imprimer({ papier: 'A4', orientation: 'paysage' }), reglage); + assert.equal(imprimante.appels().length, 2); + } + }); + + test('le relevé est une copie : ni les options reçues, ni le tableau rendu ne le changent ; seuls papier et orientation y entrent', async () => { + const imprimante = creerImprimanteSimulee(); + const options = { papier: 'A4', orientation: 'portrait', silent: true }; + await imprimante.imprimer(options); + options.papier = 'Lettre'; + imprimante.appels().push({ papier: 'A4', orientation: 'paysage' }); + imprimante.appels()[0].orientation = 'paysage'; + assert.deepEqual(imprimante.appels(), [{ papier: 'A4', orientation: 'portrait' }]); + }); + + test('une option hors domaine rejette TypeError, comme les deux imprimantes réelles, et ne se relève pas', async () => { + const imprimante = creerImprimanteSimulee(); + for (const options of [ + { papier: 'A3', orientation: 'portrait' }, + { papier: 'toString', orientation: 'portrait' }, + { papier: 'A4', orientation: 'landscape' }, + { papier: 'A4' }, + undefined, + ]) { + await assert.rejects(imprimante.imprimer(options), TypeError, JSON.stringify(options)); + } + assert.deepEqual(imprimante.appels(), []); + }); + + test("un réglage hors du contrat se refuse à la construction : issue inconnue, cause d'un autre type, cause pour une issue qui n'en porte pas", () => { + for (const reglage of [ + { issue: 'imprime' }, + { issue: 'echec', cause: 3 }, + { issue: 'envoye', cause: 'failed' }, + { issue: 'annule', cause: 'cancelled' }, + null, + ]) { + assert.throws(() => creerImprimanteSimulee(reglage), TypeError, JSON.stringify(reglage)); + } + }); +}); diff --git a/test/navigateur/montage.js b/test/navigateur/montage.js index 25790ab..16c8a62 100644 --- a/test/navigateur/montage.js +++ b/test/navigateur/montage.js @@ -5,7 +5,9 @@ // § 14.10) : App reçoit des plateformes d'épreuve par ses propriétés, comme // src/main.js lui donne celles de la machine — le système de fichiers simulé // de test/fichiers_simules.js, l'horloge pilotée, l'exécuteur direct de la -// génération, un presse-papiers qui enregistre. Chaque montage vit dans son +// génération, un presse-papiers qui enregistre, l'imprimante d'épreuve de +// test/imprimante_simulee.js, qui relève ce qu'on lui demande d'imprimer. +// Chaque montage vit dans son // propre conteneur, ajouté au corps du document : deux montages simultanés // ne partagent ni système, ni séance, ni identifiants. // @@ -33,12 +35,14 @@ import { attendrePolices, chargerPolices } from '../../src/polices/chargement.js import { FORMAT } from '../../src/stockage/document.js'; import { VERSION } from '../../src/version.genere.js'; import { creerFichiersSimules } from '../fichiers_simules.js'; +import { creerImprimanteSimulee } from '../imprimante_simulee.js'; /** Les primitives que le système simulé compte comme écritures, celles que * tracer relève. */ export const PRIMITIVES_TRACEES = Object.freeze([ 'sonder', 'ecrireAtomique', + 'ecrireOctetsAtomique', 'ajouterLigne', 'creerDossier', 'deplacer', @@ -69,9 +73,10 @@ function installerCollecteur() { return installation; } -/** Enveloppe les huit primitives d'écriture du système simulé — sonder, - * ecrireAtomique, ajouterLigne, creerDossier, deplacer, supprimer, - * verrouiller, deverrouiller — et rend leur relevé, dans l'ordre des appels : +/** Enveloppe les neuf primitives d'écriture du système simulé — sonder, + * ecrireAtomique, ecrireOctetsAtomique, ajouterLigne, creerDossier, + * deplacer, supprimer, verrouiller, deverrouiller — et rend leur relevé, + * dans l'ordre des appels : * [primitive, racine.id, chemin], '' pour sonder, qui ne prend pas de chemin. * Un appel se relève avant de s'exécuter, refusé ou non. */ export function tracer(fs) { @@ -111,9 +116,12 @@ export function tracer(fs) { * @param {Array<{cle: string, nom: string, texte: string}>} [options.demonstrations] * @param {() => Promise<{regulier: Uint8Array, gras: Uint8Array}>} [options.polices] * sinon chargerPolices, celui de src/main.js + * @param {Object} [options.imprimante] l'imprimante qu'App reçoit ; sinon + * une imprimante d'épreuve neuve, dont chaque impression est envoyée * @returns {Promise} { app, racine, fs, disque, trace, horloge, - * presse, polices, seance, facade, ui, erreurs(), demonter() } — polices - * est le chargement de la police qu'App a reçu ; seance est un + * presse, polices, imprimante, seance, facade, ui, erreurs(), demonter() } + * — polices est le chargement de la police qu'App a reçu, imprimante + * l'imprimante qu'il a reçue ; seance est un * accesseur de app.seance() ; presse.copies, les textes copiés ; * erreurs(), ce que le collecteur a reçu depuis ce montage ; demonter() * démonte App, retire le conteneur et rend à son data-theme d'avant @@ -130,6 +138,7 @@ export async function monterApp({ formatCourant, demonstrations, polices = chargerPolices, + imprimante = creerImprimanteSimulee(), } = {}) { await installerCollecteur(); await attendrePolices(document); @@ -179,6 +188,7 @@ export async function monterApp({ produitVersion: VERSION.affichee, versionWindows: VERSION.windows, polices, + imprimante, ...(theme === null ? {} : { themeInitial: theme }), }, }); @@ -193,6 +203,7 @@ export async function monterApp({ horloge: horlogeMontage, presse, polices, + imprimante, get seance() { return app.seance(); },