[ADD] platform: print bridge, atomic byte writes, badge sheet file name

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
This commit is contained in:
Mathieu Benoit 2026-10-09 15:26:13 -04:00
parent 8371e8f1ec
commit 8b7c732399
28 changed files with 1391 additions and 104 deletions

View file

@ -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:<primitive>.
@ -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

106
electron/impression.js Normal file
View file

@ -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));
}

View file

@ -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

View file

@ -7,15 +7,17 @@
// invoque le canal de cette primitive, gtt:fichiers:<primitive>, 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 });

View file

@ -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

View file

@ -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.`,
],
]);

View file

@ -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;
}

View file

@ -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));
}
});
});

View file

@ -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<unknown>}} 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 };
},
};
}

View file

@ -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));
}
});
});

View file

@ -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.

View file

@ -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,
},
});

View file

@ -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),

View file

@ -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');

View file

@ -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();

View file

@ -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_<papier>_feuille_<date>.pdf »,
// en tient compte sous la borne du chemin.
const REEDITION_MAX = 9999;
// Ce qui tient lieu de date et d'heure quand le placement n'en porte pas.
const HORODATAGE_INCONNU = 'horodatage-inconnu';
/**
* Nom du fichier de la planche de badges (§ 11.6), posé à la racine du
* dossier de travail : <base>.badges[-<k>]_<papier>_<imposition>_<date>.pdf,
* où k est le nombre de badges d'une réédition, papier une clé de PAPIERS,
* imposition feuille ou pile, et date le jour et l'heure de l'horodatage du
* placement, AAAA-MM-JJ_HH-MM-SS, décalage retiré — « horodatage-inconnu »
* quand il manque. Le nom ne porte aucun deux-points. Même base, même
* papier, même imposition, même placement : même nom. Le plus long suffixe,
* 51 unités, prolongé de SUFFIXE_ECRITURE, tient sous la partie fixe de
* longueurRelativeMax : la base d'un événement, que deriverBase borne, laisse
* donc place à sa planche.
*
* @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`;
}

View file

@ -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 « -<k> » 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);
});
});

View file

@ -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<void>} 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<void>} 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)}`);
}
}

View file

@ -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);
});
});

View file

@ -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':
"<script>\n const {\n fs,\n imprimante,\n } = untrack(() => dependances);\n // setContext('imprimante', imprimante);\n</script>\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), []);

View file

@ -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"}');

View file

@ -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:<primitive> par primitive, puis celui du presse-papiers, et aucun autre, chacun posé avant le chargement de la page', () => {
test("un canal gtt:fichiers:<primitive> 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 }]);
});
});

View file

@ -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,
};
}

View file

@ -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 <chemin>.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',

View file

@ -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 : <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) => {

View file

@ -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 })),
};
}

View file

@ -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));
}
});
});

View file

@ -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<Object>} { 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 à <html> 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();
},