gestion_table_tournante_libre/test/coquille.test.js
Mathieu Benoit 8b7c732399 [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
2026-10-09 15:26:13 -04:00

501 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// 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, 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 ; 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';
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));
const FACTICE = pathToFileURL(join(RACINE, 'test', 'electron_factice.js')).href;
const PRINCIPAL = join(RACINE, 'electron', 'main.js');
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.
const PUBLIE = join(sep, 'media', 'cle', 'soirees');
const LOCAL = join(sep, 'donnees', 'locales');
// Ce que la coquille voit de l'environnement, par scénario.
const SCENARIOS = {
publie: { PORTABLE_EXECUTABLE_DIR: PUBLIE, LOCALAPPDATA: LOCAL },
absent: {},
vide: { PORTABLE_EXECUTABLE_DIR: '' },
};
// Délai de l'exécution de la coquille. Le factice ne joue la page qu'une
// fois le processus sans rien à faire ; un minuteur ou un écouteur laissé
// actif l'en empêche, et l'exécution ne finit jamais. Le délai reste sous
// celui d'une épreuve de Vitest, 5 s, qui n'interrompt pas un appel
// synchrone : l'épreuve échoue en le disant, au lieu de figer la série. Le
// délai écoulé, le processus reçoit KILL, qu'aucun gestionnaire de signal
// ne retient.
const DELAI_MS = 4_000;
// Le pid d'un processus terminé : aucun processus de ce poste n'y répond
// plus.
const PID_TERMINE = spawnSync(process.execPath, ['-e', '']).pid;
// Ce que le dossier temporaire porte avant la coquille : les Documents du
// produit, où l'explorateur s'ouvre ; le profil qu'a laissé une séance
// terminée, celui d'une séance qui tourne encore — ce processus d'épreuve —,
// et un dossier dont le nom ne porte aucun pid.
const PROFIL_TERMINE = `gtt-profil-${PID_TERMINE}-Ab12Cd`;
const PROFIL_VIVANT = `gtt-profil-${process.pid}-Ef34Gh`;
const AUTRE = 'gtt-profil-sans-pid';
const DOSSIERS_POSES = ['Documents', PROFIL_TERMINE, PROFIL_VIVANT, AUTRE];
// Le verrou qu'une autre séance tient dans les Documents du produit, posé
// avant le lancement : la sortie de la coquille ne le touche pas.
const VERROU_AUTRE = 'autre.gtt.verrou';
const CONTENU_AUTRE = {
seance: 'seance-voisine',
pid: PID_TERMINE,
hote: 'poste-voisin',
depuis: '2026-10-06T19:00:00-04:00',
demarrage: null,
};
// Exécute la coquille contre le factice dans un scénario, et rend son
// journal, son pid, le dossier temporaire qu'elle a reçu, et ce qu'elle y a
// laissé. Le répertoire courant est un dossier vide hors du projet : dans
// l'exécutable, il n'est jamais l'intérieur d'app.asar, si bien qu'un chemin
// résolu contre lui ne désigne ni la page ni le préchargement. Le dossier
// temporaire est neuf, et porte les DOSSIERS_POSES.
function executerLaCoquille(scenario) {
const environnement = { ...process.env };
delete environnement.PORTABLE_EXECUTABLE_DIR;
delete environnement.LOCALAPPDATA;
const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-'));
const temporaire = mkdtempSync(join(tmpdir(), 'coquille-temp-'));
mkdirSync(join(temporaire, 'Documents', NOM_PRODUIT), { recursive: true });
writeFileSync(join(temporaire, 'Documents', NOM_PRODUIT, VERROU_AUTRE), `${JSON.stringify(CONTENU_AUTRE)}\n`);
for (const nom of [PROFIL_TERMINE, PROFIL_VIVANT, AUTRE]) {
mkdirSync(join(temporaire, nom, 'Session Storage'), { recursive: true });
writeFileSync(join(temporaire, nom, 'Local State'), '{}');
}
try {
const resultat = spawnSync(process.execPath, ['--import', FACTICE, PRINCIPAL], {
cwd: ailleurs,
encoding: 'utf8',
env: { ...environnement, TMPDIR: temporaire, TEMP: temporaire, TMP: temporaire, ...SCENARIOS[scenario] },
timeout: DELAI_MS,
killSignal: 'SIGKILL',
});
assert.notEqual(
resultat.error?.code,
'ETIMEDOUT',
`la coquille ne s'arrête pas en ${DELAI_MS} ms : un minuteur ou un écouteur la tient en vie`,
);
assert.equal(resultat.status, 0, `la coquille ne s'exécute pas :\n${resultat.stderr}`);
return {
journal: JSON.parse(resultat.stdout),
pid: resultat.pid,
temporaire,
restes: readdirSync(temporaire).sort(),
};
} finally {
rmSync(ailleurs, { recursive: true, force: true });
rmSync(temporaire, { recursive: true, force: true });
}
}
// Chaque scénario ne s'exécute qu'une fois, et son échec se rend à chaque
// épreuve qui le demande, sans relancer.
const executions = new Map();
function coquille(scenario = 'publie') {
if (!executions.has(scenario)) {
try {
executions.set(scenario, { resultat: executerLaCoquille(scenario) });
} catch (erreur) {
executions.set(scenario, { erreur });
}
}
const execution = executions.get(scenario);
if (execution.erreur !== undefined) throw execution.erreur;
return execution.resultat;
}
const journalDeLaCoquille = (scenario) => coquille(scenario).journal;
// L'appel de la page à une fonction du pont, consigné par le factice.
const appelDe = (journal, membre) => journal.prechargement.appels.find((appel) => appel.membre === membre);
describe('coquille : processus principal', () => {
test("une seule fenêtre, 1366 × 768 : page isolée, sans Node, en bac à sable, préchargée par le pont", () => {
const { fenetres } = journalDeLaCoquille();
assert.equal(fenetres.length, 1);
const [{ width, height, webPreferences }] = fenetres;
assert.deepEqual({ width, height }, { width: 1366, height: 768 });
assert.deepEqual(webPreferences, {
preload: PRECHARGEMENT,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
});
assert.ok(existsSync(webPreferences.preload), `${webPreferences.preload} absent`);
});
test("aucun menu d'application : le menu par défaut d'Electron, en anglais, est retiré avant la première fenêtre (§ 2)", () => {
const { menus, sequence } = journalDeLaCoquille();
assert.deepEqual(menus, [null]);
const retrait = sequence.indexOf('Menu.setApplicationMenu');
const fenetre = sequence.indexOf('new BrowserWindow');
assert.ok(retrait !== -1 && fenetre !== -1 && retrait < fenetre, `ordre des appels : ${sequence.join(', ')}`);
});
test('la fenêtre charge la page que Vite construit dans www/', () => {
assert.deepEqual(journalDeLaCoquille().chargements, [
{ loadFile: join(RACINE, 'www', 'index.html') },
]);
});
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.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, CANAL_IMPRIMER]) {
const pose = sequence.indexOf(`ipcMain.handle:${canal}`);
assert.ok(pose !== -1 && pose < charge, `${canal} : ${sequence.join(', ')}`);
}
});
test("la page n'ouvre aucune fenêtre et ne navigue nulle part", () => {
const { ouvertures, navigations } = journalDeLaCoquille();
assert.deepEqual(ouvertures, [{ action: 'deny' }]);
assert.ok(navigations.length > 0, 'aucun écouteur de will-navigate');
assert.deepEqual(navigations, navigations.map(() => true));
});
test('le profil de Chromium est un dossier neuf sous le dossier temporaire, nommé par le pid de la séance, posé avant whenReady, retiré à la sortie (§ 8.6)', () => {
const { journal, pid, temporaire } = coquille();
assert.equal(journal.setPath.length, 1, JSON.stringify(journal.setPath));
const [{ nom, chemin }] = journal.setPath;
assert.equal(nom, 'userData');
assert.equal(dirname(chemin), temporaire);
assert.ok(basename(chemin).startsWith(`gtt-profil-${pid}-`), basename(chemin));
const pose = journal.sequence.indexOf('app.setPath:userData');
const pret = journal.sequence.indexOf('app.whenReady');
assert.ok(pose !== -1 && pret !== -1 && pose < pret, `ordre des appels : ${journal.sequence.join(', ')}`);
assert.deepEqual(journal.profil, { avant: true, apres: false });
});
test("à la sortie, le verrou que la page a pris s'efface ; celui qu'une autre séance a repris depuis, et celui d'une autre séance, restent (§ 8.8)", () => {
for (const scenario of ['publie', 'absent']) {
const { prechargement, verrous } = journalDeLaCoquille(scenario);
assert.deepEqual(prechargement.verrou.reponse, { ok: true, valeur: { pris: true } }, scenario);
// Le second verrou de la page est au relevé de la coquille, mais son
// contenu porte désormais une autre séance : la sortie le relit et le
// laisse.
assert.deepEqual(prechargement.repris.reponse, { ok: true, valeur: { pris: true } }, scenario);
assert.deepEqual(
verrous,
{
page: { avant: true, apres: false },
repris: { avant: true, apres: true },
autre: { avant: true, apres: true },
},
scenario,
);
}
});
test("à la sortie, la coquille confie les verrous tenus à rendreVerrousTenus, seul lecteur de leur format : elle n'en analyse aucun", () => {
const texte = readFileSync(PRINCIPAL, 'utf8');
assert.match(texte, /app\.on\('quit', \(\) => fichiers\.rendreVerrousTenus\(\)\)/);
assert.doesNotMatch(texte, /JSON\.parse|\.verrousTenus\(/);
});
test("au démarrage, les profils des séances terminées se retirent : ni celui d'une séance vivante, ni un dossier sans pid", () => {
const { restes } = coquille();
assert.deepEqual(restes, DOSSIERS_POSES.filter((nom) => nom !== PROFIL_TERMINE).sort());
});
});
describe('coquille : préchargement', () => {
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', '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 })),
PRIMITIVES.map((membre) => ({ membre, type: 'function' })),
);
});
test('chaque fonction invoque son propre canal, une fois par appel, et rend la réponse du processus principal', () => {
const journal = journalDeLaCoquille();
assert.deepEqual(
journal.prechargement.appels.map(({ membre, canaux }) => ({ membre, canaux })),
PRIMITIVES.map((membre) => ({ membre, canaux: [`gtt:fichiers:${membre}`] })),
);
// 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.
for (const [membre, chemin] of [
['sonder', ''],
['typeSupport', ''],
['lireTexte', 'soiree.gtt.json'],
['ecrireAtomique', 'soiree.gtt.json'],
['ecrireOctetsAtomique', 'soiree.badges.pdf'],
['ajouterLigne', 'soiree.gtt-journal.jsonl'],
['lister', ''],
['creerDossier', 'corbeille'],
['deplacer', 'soiree.gtt.json'],
['supprimer', 'soiree.gtt.json'],
['verrouiller', 'soiree.gtt.verrou'],
['deverrouiller', 'soiree.gtt.verrou'],
]) {
assert.deepEqual(
appelDe(journal, membre).reponse,
{ ok: false, code: 'CHEMIN_REFUSE', details: { chemin } },
membre,
);
}
});
test("emplacements et racines viennent du processus principal : le dossier publié et son data/, AppData, LOCALAPPDATA, le temporaire, les Documents du produit", () => {
const journal = journalDeLaCoquille('publie');
const { chemins } = journal;
assert.deepEqual(appelDe(journal, 'emplacements').reponse, {
ok: true,
valeur: {
executable: PUBLIE,
donneesApplicatives: [chemins.appData, LOCAL, chemins.temp],
insensibleCasse: process.platform === 'win32' || process.platform === 'darwin',
separateur: sep,
},
});
assert.deepEqual(appelDe(journal, 'racines').reponse, {
ok: true,
valeur: {
portable: { id: 'portable', chemin: join(PUBLIE, 'data') },
documents: { id: 'documents', chemin: join(chemins.documents, NOM_PRODUIT) },
},
});
});
test('sans dossier publié, ou publié vide, ni exécutable ni racine portable', () => {
for (const scenario of ['absent', 'vide']) {
const journal = journalDeLaCoquille(scenario);
const { chemins } = journal;
assert.deepEqual(
appelDe(journal, 'emplacements').reponse.valeur,
{
executable: null,
donneesApplicatives: [chemins.appData, chemins.temp],
insensibleCasse: process.platform === 'win32' || process.platform === 'darwin',
separateur: sep,
},
scenario,
);
assert.deepEqual(
appelDe(journal, 'racines').reponse.valeur,
{ portable: null, documents: { id: 'documents', chemin: join(chemins.documents, NOM_PRODUIT) } },
scenario,
);
}
});
test("les dialogues s'ouvrent sur la fenêtre de la page, et une annulation rend null", () => {
const journal = journalDeLaCoquille();
assert.deepEqual(journal.dialogues, [
{ methode: 'showOpenDialog', parent: true, options: { properties: ['openDirectory', 'createDirectory'] } },
{ methode: 'showOpenDialog', parent: true, options: { properties: ['openFile'] } },
{ methode: 'showSaveDialog', parent: true, options: { defaultPath: join(journal.chemins.documents, 'liste.csv') } },
]);
for (const membre of ['choisirDossier', 'choisirFichierAImporter', 'enregistrerSous']) {
assert.deepEqual(appelDe(journal, membre).reponse, { ok: true, valeur: null }, membre);
}
});
test('gtt.copier invoque le canal gtt:copier, une fois, et le presse-papiers reçoit le texte tel quel', () => {
const { prechargement, pressePapiers } = journalDeLaCoquille();
const { copie } = prechargement;
assert.deepEqual(copie.canaux, [CANAL_COPIE]);
assert.equal(copie.erreur, undefined, copie.erreur);
assert.equal(typeof copie.texte, 'string');
assert.notEqual(copie.texte, '');
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)]);
// La réponse porte valeur: undefined, que le journal en JSON ne garde pas.
assert.deepEqual(appelDe(journal, 'ouvrirDansExplorateur').reponse, { ok: true });
});
});
describe('coquille : la page', () => {
test("index.html charge le collecteur d'erreurs, script classique, avant le module d'entrée (§ 19.2)", () => {
const page = readFileSync(join(RACINE, 'index.html'), 'utf8');
const scripts = [...page.matchAll(/<script\b([^>]*)>/g)].map(([, attributs]) => attributs.trim());
assert.deepEqual(scripts, ['src="./collecteur.js"', 'type="module" src="/src/main.js"']);
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 }]);
});
});