[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:
parent
8371e8f1ec
commit
8b7c732399
28 changed files with 1391 additions and 104 deletions
|
|
@ -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
106
electron/impression.js
Normal 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));
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
58
src/impression/imprimante_electron.js
Normal file
58
src/impression/imprimante_electron.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
101
src/impression/imprimante_electron.test.js
Normal file
101
src/impression/imprimante_electron.test.js
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
19
src/main.js
19
src/main.js
|
|
@ -3,14 +3,15 @@
|
|||
|
||||
// Point d'entrée de la page : le seul module de src/ qui choisit la
|
||||
// plateforme (§ 13.4). Sous la coquille Electron, le préchargement expose
|
||||
// window.gtt — le système de fichiers du processus principal et la copie
|
||||
// dans le presse-papiers ; sans lui, la page est la plateforme web, sur
|
||||
// l'OPFS et le presse-papiers du navigateur. Il construit les dépendances
|
||||
// d'App — l'horloge du système, l'exécuteur de la génération sur le
|
||||
// travailleur, les identifiants tirés par crypto.randomUUID, les
|
||||
// window.gtt — le système de fichiers du processus principal, la copie
|
||||
// dans le presse-papiers et l'impression ; sans lui, la page est la
|
||||
// plateforme web, sur l'OPFS, le presse-papiers et la boîte d'impression
|
||||
// du navigateur. L'imprimante (src/impression), frontière de plateforme
|
||||
// comme le système de fichiers, ne s'importe que d'ici. Il construit les
|
||||
// dépendances d'App — l'horloge du système, l'exécuteur de la génération sur
|
||||
// le travailleur, les identifiants tirés par crypto.randomUUID, les
|
||||
// démonstrations livrées nommées par le catalogue, le chargement de la
|
||||
// police embarquée — et monte App dans le
|
||||
// corps du document. Le thème de départ est laissé à App, qui lit la
|
||||
// police embarquée, l'imprimante — et monte App dans le corps du document. Le thème de départ est laissé à App, qui lit la
|
||||
// préférence du système.
|
||||
//
|
||||
// Il est le seul module qui importe le travailleur de génération, par
|
||||
|
|
@ -36,6 +37,8 @@ import { mount } from 'svelte';
|
|||
import { creerExecuteurTravailleur } from './application/executeur_travailleur.js';
|
||||
import { creerHorlogeSysteme } from './application/horloge.js';
|
||||
import { demonstrationsLivrees } from './demo/livrees.js';
|
||||
import { creerImprimanteElectron } from './impression/imprimante_electron.js';
|
||||
import { creerImprimanteWeb } from './impression/imprimante_web.js';
|
||||
import App from './interface/App.svelte';
|
||||
import Travailleur from './interface/generation/travailleur.js?worker&inline';
|
||||
import { attendrePolices, chargerPolices } from './polices/chargement.js';
|
||||
|
|
@ -51,6 +54,7 @@ const fs = sousElectron ? creerFichiersElectron(pont.fichiers) : creerFichiersWe
|
|||
const pressePapiers = sousElectron
|
||||
? { copier: async (texte) => void (await pont.copier(texte)) }
|
||||
: { copier: (texte) => navigator.clipboard.writeText(texte) };
|
||||
const imprimante = sousElectron ? creerImprimanteElectron(pont) : creerImprimanteWeb(window);
|
||||
|
||||
try {
|
||||
await attendrePolices(document);
|
||||
|
|
@ -75,5 +79,6 @@ mount(App, {
|
|||
produitVersion: VERSION.affichee,
|
||||
versionWindows: VERSION.windows,
|
||||
polices: chargerPolices,
|
||||
imprimante,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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), []);
|
||||
|
|
|
|||
|
|
@ -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"}');
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
52
test/imprimante_simulee.js
Normal file
52
test/imprimante_simulee.js
Normal 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 })),
|
||||
};
|
||||
}
|
||||
74
test/imprimante_simulee.test.js
Normal file
74
test/imprimante_simulee.test.js
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue