// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // La coquille de l'application dans un vrai moteur de rendu : le titre de la // fenêtre et le bandeau (§ 8.4, § 18.6), « Liste des événements » (§ 8.1), // le panneau « À propos », le réglage de thème (§ 19.2), le collecteur // d'erreurs, l'injection des plateformes (§ 13.4), l'ordre du montage, la // liste relue et l'abandon des résumés d'une liste dépassée (§ 8.1), et la // fermeture sans question (§ 8.4, § 8.8). Chaque épreuve monte App par // monterApp (test/navigateur/montage.js), sur le système de fichiers simulé. // // Le test interroge le rendu, jamais la présence d'une classe (§ 14.2) : la // visibilité de chaque élément, la boîte où la mise en page le pose et // l'élément qu'atteint le centre de cette boîte, le style calculé, le texte, // le titre de la fenêtre. Il ne lit aucun pixel : le contraste se calcule sur // les couleurs calculées, qui ne sont les couleurs peintes que sans opacité // partielle, filtre ni mode de fusion — ce qu'une épreuve vérifie dans // l'ascendance de chaque texte (§ 14.5). Les épreuves de visibilité, de // contraste et d'opacité passent par les deux thèmes, que l'attribut // data-theme de choisit (§ 19.2). Les mesures viennent du module // partagé test/navigateur/rendu.js, dont le contraste est celui de // palette.js, seule formule des WCAG du dépôt. // // Le projet navigateur n'importe pas node:assert/strict : côté navigateur, // Vite le remplace par un module qui lève une erreur à tout accès. Il emploie // expect, et prend test et describe chez Vitest plutôt que dans // test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node. import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { cdp } from 'vitest/browser'; import { flushSync } from 'svelte'; import { appliquerCommande } from '../application/commandes.js'; import { cibleRedondance, evaluer } from '../application/evaluation.js'; import { creerFormat } from '../application/format.js'; import { TABLES_APPLICATION } from '../application/libelles.js'; import { creerTraduction } from '../application/traduction.js'; import { LIVREES } from '../demo/livrees.js'; import { rechercher } from '../moteur/recherche.js'; import { serialiser } from '../stockage/canonique.js'; import { FORMAT, analyser, configurationDepuisCharge } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { VERSION } from '../version.genere.js'; import COLLECTEUR from '../../public/collecteur.js?raw'; import { monterApp } from '../../test/navigateur/montage.js'; import { attenuations, composantes, contraste, couleurTexte, designation, fondPeintSous, porteursDeTexte, sousTheme, visible, } from '../../test/navigateur/rendu.js'; import { blocAPropos } from './apropos.js'; import { TABLES_INTERFACE, libelleProvenance } from './libelles.js'; import { SECTION_OUVERTURE } from './evenement/sections.js'; import { cleDeMode } from './mode.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const format = creerFormat(t); const TRANSPARENT = 'rgba(0, 0, 0, 0)'; const THEMES = ['clair', 'sombre']; // Un événement d'épreuve : le fichier d'état d'une démonstration livrée, son // nom remplacé par un nom inventé, au format donné. const PETITE = LIVREES.find(({ cle }) => cle === 'petite').texte; const CONFLIT = LIVREES.find(({ cle }) => cle === 'petite-conflit').texte; function evenement(nom, { texte = PETITE, format = FORMAT } = {}) { const renomme = texte.replace(/"nom":"[^"]*"/, `"nom":${JSON.stringify(nom)}`); const auFormat = renomme.replace(`"format":${FORMAT}`, `"format":${format}`); if (!renomme.includes(JSON.stringify(nom)) || !auFormat.includes(`"format":${format}`)) { throw new Error("le fichier d'épreuve ne se compose pas"); } return auFormat; } const fichier = (base, nom, options) => ({ racine: 'documents', chemin: `${base}.gtt.json`, texte: evenement(nom, options) }); const verrouDe = (m, base) => m.fs.contenu('documents', `${base}.gtt.verrou`); // Les montages de l'épreuve en cours, démontés après elle ; le collecteur // n'a rien reçu pendant l'épreuve, sauf quand elle le veut. let montes = []; let erreursAdmises = false; let erreursAvant = 0; async function monter(options) { const m = await monterApp(options); montes.push(m); return m; } beforeEach(() => { erreursAvant = globalThis.__gttErreurs?.length ?? 0; erreursAdmises = false; }); afterEach(() => { for (const m of montes.reverse()) m.demonter(); montes = []; vi.restoreAllMocks(); const recues = globalThis.__gttErreurs?.slice(erreursAvant) ?? []; if (!erreursAdmises) expect(recues, 'erreurs reçues par le collecteur').toEqual([]); }); const dans = (m, selecteur) => m.racine.querySelector(selecteur); const bandeau = (m) => dans(m, '[data-bandeau]'); const libelleDeMode = (m) => dans(m, '[data-mode-libelle="bandeau"]'); const ecran = (m) => dans(m, '[data-ecran]'); // Un montage dont l'événement « Bal des Grisailles » est ouvert, en lecture. async function avecEvenementOuvert(options = {}) { const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')], ...options }); await m.seance.ouvrir('bal'); flushSync(); return m; } // Le bandeau, puis chacun de ses porteurs de texte. const bandeauEtTextes = (m) => [{ element: bandeau(m), pseudo: null }, ...porteursDeTexte(bandeau(m))]; describe('titre et bandeau (§ 8.4, § 18.6)', () => { test('le titre de la fenêtre vaut exactement le nom suivi de la version affichée', async () => { await monter(); expect(document.title).toBe(`Gestion table tournante Libre — ${VERSION.affichee}`); }); test('le bandeau est visible, dans la fenêtre, et porte la version affichée et la provenance ; sans événement, aucun libellé de mode', async () => { const m = await monter(); expect(visible(bandeau(m))).toBe(true); expect(bandeau(m).textContent).toContain(VERSION.affichee); expect(bandeau(m).textContent).toContain(libelleProvenance('test')); expect(libelleDeMode(m)).toBeNull(); }); test('un événement en lecture, en écriture, puis bloqué : le libellé de mode de chaque état, de la clé que rend cleDeMode', async () => { const m = await avecEvenementOuvert(); const attendus = []; const relever = () => { flushSync(); const cle = cleDeMode(m.seance.etat()); attendus.push(cle); expect(visible(libelleDeMode(m))).toBe(true); expect(libelleDeMode(m).textContent).toBe(t(cle)); }; relever(); await m.seance.passerEnEcriture(); relever(); await m.seance.executer('changerEtat', { etat: 'bloque' }); relever(); expect(attendus).toEqual(['coquille.mode.lecture', 'coquille.mode.ecriture', 'coquille.mode.bloque']); }); test('le bandeau ne défile pas : la page défilée jusqu’en bas, il reste dans la fenêtre et son centre l’atteint', async () => { const m = await monter(); const haut = document.createElement('div'); haut.style.height = '3000px'; ecran(m).append(haut); window.scrollTo(0, document.documentElement.scrollHeight); expect(window.scrollY).toBeGreaterThan(0); const boite = bandeau(m).getBoundingClientRect(); expect(boite.top).toBeGreaterThanOrEqual(0); expect(boite.bottom).toBeLessThanOrEqual(window.innerHeight); const atteint = document.elementFromPoint(boite.left + boite.width / 2, boite.top + boite.height / 2); expect(bandeau(m).contains(atteint)).toBe(true); window.scrollTo(0, 0); }); test('construction de documentation : la marque remplace le numéro ; en livraison, le numéro', async () => { const documentation = await monter({ provenance: 'documentation' }); expect(bandeau(documentation).textContent).not.toContain(VERSION.affichee); expect(bandeau(documentation).textContent).toContain(t('coquille.version.documentation')); const accueil = (m) => dans(m, '[data-ecran="accueil"]'); expect(accueil(documentation).textContent).not.toContain(VERSION.affichee); expect(accueil(documentation).textContent).toContain(t('coquille.version.documentation')); documentation.demonter(); montes = []; const livraison = await monter({ provenance: 'livraison' }); expect(bandeau(livraison).textContent).toContain(VERSION.affichee); expect(bandeau(livraison).textContent).toContain(libelleProvenance('livraison')); expect(accueil(livraison).textContent).toContain(VERSION.affichee); expect(accueil(livraison).textContent).not.toContain(t('coquille.version.documentation')); }); }); describe('annonce de format (§ 8.8, § 18.6)', () => { const annonce = (m) => dans(m, '[data-avertissement="annonceFormat"]'); test('un fichier au format FORMAT + 1 : « ce fichier s’ouvre en lecture seule » dans le bandeau', async () => { const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles', { format: FORMAT + 1 })] }); await m.seance.ouvrir('bal'); flushSync(); expect(bandeau(m).contains(annonce(m))).toBe(true); expect(annonce(m).textContent).toBe(t('coquille.format.plusRecent')); expect(annonce(m).textContent).toMatch(/s.ouvre en lecture seule/); }); test('au format courant : aucune annonce', async () => { const m = await avecEvenementOuvert(); expect(annonce(m)).toBeNull(); }); test('formatCourant à FORMAT + 1, fichier au format FORMAT : « ce fichier se convertira… », et l’ouverture n’ajoute rien au relevé', async () => { const m = await monter({ formatCourant: FORMAT + 1, fichiers: [fichier('bal', 'Bal des Grisailles')] }); const avant = m.trace.length; await m.seance.ouvrir('bal'); flushSync(); expect(annonce(m).textContent).toBe(t('coquille.format.plusAncien')); expect(annonce(m).textContent).toMatch(/se convertira à votre première modification/); expect(m.trace.length).toBe(avant); }); }); describe('« Liste des événements » (§ 8.1, § 8.4)', () => { const commande = (m) => dans(m, '[data-commande="liste"]'); test('absente sans événement ouvert', async () => { const m = await monter(); expect(commande(m)).toBeNull(); }); test('un événement en écriture, la commande cliquée : l’accueil revient, sans fenêtre ni question, le verrou rendu, aucun libellé de mode', async () => { const fenetres = ['confirm', 'alert', 'prompt'].map((nom) => vi.spyOn(window, nom)); const m = await avecEvenementOuvert(); await m.seance.passerEnEcriture(); expect(verrouDe(m, 'bal')).not.toBeNull(); flushSync(); expect(visible(commande(m))).toBe(true); commande(m).click(); await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull()); flushSync(); expect(ecran(m).dataset.ecran).toBe('accueil'); expect(verrouDe(m, 'bal')).toBeNull(); expect(libelleDeMode(m)).toBeNull(); for (const fenetre of fenetres) expect(fenetre).not.toHaveBeenCalled(); }); test('sous un support rompu, la séance refuse de fermer : le refus s’affiche à côté de la commande, et l’événement reste ouvert', async () => { const m = await avecEvenementOuvert(); await m.seance.passerEnEcriture(); m.fs.pannes.ecriture('documents'); await expect(m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } })).rejects.toMatchObject({ code: 'ECRITURE', }); expect(m.seance.etat().supportRompu).not.toBeNull(); const leve = await m.seance.fermer().then( () => null, (erreur) => erreur, ); expect(leve?.code).toBe('SUPPORT_ROMPU_NON_SAUVE'); flushSync(); commande(m).click(); const refus = await vi.waitFor(() => { const element = dans(m, '[data-refus="liste"]'); expect(element).not.toBeNull(); return element; }); const remede = leve.details.remede; expect(remede.geste).toBe('ecrireAilleurs'); const cleRemede = `communs.remede.ecrireAilleurs.${remede.racine ? 'documents' : 'choisi'}`; expect(refus.textContent).toContain(t(leve.code, leve.details)); const commandeRemede = refus.querySelector('[data-commande="ecrireAilleurs"]'); expect(commandeRemede?.textContent).toBe(t(cleRemede)); expect(visible(refus)).toBe(true); expect(visible(commandeRemede)).toBe(true); // Posé dans le bandeau, le refus garde sa lisibilité : chaque texte tient // 4,5:1 sur chaque couleur peinte sous lui, dans les deux thèmes. for (const theme of THEMES) { await sousTheme(theme, () => { for (const porteur of porteursDeTexte(refus)) { const lieu = `${theme} : ${designation(porteur)}`; const texte = couleurTexte(porteur); for (const fond of fondPeintSous(porteur)) { expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5); } } }); } expect(m.seance.etat().evenement?.base).toBe('bal'); expect(ecran(m).dataset.ecran).toBe('evenement'); }); }); describe('la liste relue', () => { test('un événement chargé puis fermé : la liste revenue le porte', async () => { const m = await monter(); expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0); const { base } = await m.facade.chargerDemonstration('petite'); flushSync(); expect(ecran(m).dataset.ecran).toBe('evenement'); dans(m, '[data-commande="liste"]').click(); await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull()); await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1)); expect(dans(m, '[data-evenement]').dataset.evenement).toBe(base); }); test('une relecture refusée : le refus traduit tient lieu de liste, sans ligne d’avant ; la relecture suivante le retire', async () => { const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] }); expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1); expect(dans(m, '[data-refus="accueil"]')).toBeNull(); // L'ouverture relève aussi le dossier : le relevé ne se refuse qu'après // elle. await m.seance.ouvrir('bal'); const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' }); const lister = m.fs.lister; m.fs.lister = async () => { throw refuse; }; await m.seance.fermer(); const refus = await vi.waitFor(() => { const element = dans(m, '[data-refus="accueil"]'); expect(element).not.toBeNull(); return element; }); expect(refus.textContent).toBe(t(refuse.code, refuse.details)); expect(visible(refus)).toBe(true); expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0); m.fs.lister = lister; await m.seance.ouvrir('bal'); await m.seance.fermer(); await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1)); expect(dans(m, '[data-refus="accueil"]')).toBeNull(); }); test('une détermination refusée : pret se résout, et l’accueil montre le refus traduit, sans ligne', async () => { // Un disque dont le relevé d'un dossier se refuse : la détermination du // dossier de travail, qui relève le dossier retenu, rejette. const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' }); class DisqueIllisible extends Map { values() { throw refuse; } } const m = await monter({ systeme: { disque: new DisqueIllisible() } }); expect(ecran(m)?.dataset.ecran).toBe('accueil'); const refus = dans(m, '[data-refus="accueil"]'); expect(refus?.textContent).toBe(t(refuse.code, refuse.details)); expect(visible(refus)).toBe(true); expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0); }); test('une détermination refusée au montage, puis réussie par un nouveau demarrer : la liste se lit, et le refus part', async () => { // Un disque dont seul le premier relevé se refuse : la façade ne garde // rien d'une détermination refusée, et le demarrer suivant la refait. const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' }); let releves = 0; class DisqueUneFoisIllisible extends Map { values() { releves += 1; if (releves === 1) throw refuse; return super.values(); } } const m = await monter({ systeme: { disque: new DisqueUneFoisIllisible() } }); expect(dans(m, '[data-refus="accueil"]')?.textContent).toBe(t(refuse.code, refuse.details)); await m.facade.demarrer(); await vi.waitFor(() => expect(dans(m, '[data-refus="accueil"]')).toBeNull()); }); test('deux lectures qui se chevauchent : la plus ancienne, finie la dernière, ne remet pas sa liste', async () => { const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] }); expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1); // La première lecture d'après la fermeture reste suspendue sur le // verrou de « bal », après avoir relevé le dossier. let liberer; const barriere = new Promise((resoudre) => { liberer = resoudre; }); let suspendue = null; const lireTexte = m.fs.lireTexte; m.fs.lireTexte = (racine, chemin) => { if (chemin === 'bal.gtt.verrou' && suspendue === null) { suspendue = barriere.then(() => lireTexte.call(m.fs, racine, chemin)); return suspendue; } return lireTexte.call(m.fs, racine, chemin); }; await m.seance.ouvrir('bal'); await m.seance.fermer(); await vi.waitFor(() => expect(suspendue).not.toBeNull()); // Un second événement paraît ; une seconde fermeture relit la liste, qui // le porte. m.fs.deposer('documents', 'club.gtt.json', evenement('Club des Merles', { texte: CONFLIT })); await m.seance.ouvrir('bal'); await m.seance.fermer(); await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2)); liberer(); await suspendue.catch(() => undefined); // Le verrou est la dernière lecture de la liste suspendue : une image // plus tard, elle a rendu ses lignes. await new Promise((resoudre) => requestAnimationFrame(() => resoudre())); flushSync(); expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2); }); }); describe('les résumés de la liste relue (§ 8.1)', () => { // La petite démonstration au conflit inévitable, deux propositions du // moteur, la seconde retenue, sous la cible de redondance donnée ; la // cible par défaut n'a pas de réglage écrit. function texteRetenu(cible) { let charge = analyser(CONFLIT).charge; const configuration = configurationDepuisCharge(charge); const propositions = rechercher(configuration, { graine: 6, nombre: 2, arret: 300 }); charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; charge = appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge; if (cible !== cibleRedondance(charge)) { charge = appliquerCommande('changerCibleRedondance', charge, { cible: String(cible) }).charge; } return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); } // La différence à la cible que la ligne d'un texte d'état écrit. const difference = (texte) => { const charge = analyser(texte).charge; return format.nombre(Math.abs(cibleRedondance(charge) - evaluer(charge).retenu.mesures.totalRedondance)); }; const cellule = (m) => dans(m, '[data-evenement="bal"] [data-colonne="diffRedondance"]'); const uneImage = () => new Promise((resoudre) => requestAnimationFrame(() => resoudre())); // Retient la prochaine lecture de l'état de « bal » : elle lit le disque à // son appel, et ne rend ce qu'elle a lu qu'à liberer(). function retenirLecture(m) { const lireTexte = m.fs.lireTexte; let liberer; const barriere = new Promise((resoudre) => { liberer = resoudre; }); const retenue = { promesse: null, liberer: () => liberer() }; m.fs.lireTexte = (racine, chemin) => { if (chemin === 'bal.gtt.json' && retenue.promesse === null) { const lue = lireTexte.call(m.fs, racine, chemin); retenue.promesse = barriere.then(() => lue); return retenue.promesse; } return lireTexte.call(m.fs, racine, chemin); }; return retenue; } test('une relecture pendant le chargement : la ligne ne montre jamais la valeur de l’ancienne lecture, même finie la dernière', async () => { const ancien = texteRetenu(1000); const nouveau = texteRetenu(25); expect(difference(ancien)).not.toBe(difference(nouveau)); const m = await monter({ fichiers: [{ racine: 'documents', chemin: 'bal.gtt.json', texte: ancien }] }); const retenue = retenirLecture(m); // Le premier pas de la première lecture lit l'état, et reste suspendu. await vi.waitFor(async () => { await uneImage(); m.horloge.avancer(0); expect(retenue.promesse).not.toBeNull(); }); m.fs.deposer('documents', 'bal.gtt.json', nouveau); await m.facade.demarrer(); flushSync(); expect(cellule(m).querySelector('[data-chargement]')).not.toBeNull(); await vi.waitFor(async () => { await uneImage(); m.horloge.avancer(0); flushSync(); expect(cellule(m).textContent).toBe(difference(nouveau)); }); // La lecture d'avant s'achève : son calcul ne se pose pas. retenue.liberer(); await retenue.promesse; for (let image = 0; image < 3; image += 1) await uneImage(); m.horloge.avancer(0); flushSync(); expect(cellule(m).textContent).toBe(difference(nouveau)); }); test('un événement ouvert pendant le chargement l’abandonne : l’horloge avancée ne relit plus aucun état', async () => { const m = await monter({ fichiers: [ { racine: 'documents', chemin: 'bal.gtt.json', texte: texteRetenu(1000) }, { racine: 'documents', chemin: 'club.gtt.json', texte: evenement('Club des Merles', { texte: CONFLIT }) }, ], }); expect(m.racine.querySelectorAll('[data-chargement]').length).toBeGreaterThan(0); await m.seance.ouvrir('bal'); flushSync(); const lireTexte = m.fs.lireTexte; const etats = []; m.fs.lireTexte = (racine, chemin) => { if (chemin.endsWith('.gtt.json')) etats.push(chemin); return lireTexte.call(m.fs, racine, chemin); }; for (let pas = 0; pas < 4; pas += 1) { await uneImage(); m.horloge.avancer(0); } expect(etats).toEqual([]); }); test('une lecture de la liste qui s’achève sous un événement ouvert ne lance aucun chargement', async () => { const m = await monter({ fichiers: [ { racine: 'documents', chemin: 'bal.gtt.json', texte: texteRetenu(1000) }, { racine: 'documents', chemin: 'club.gtt.json', texte: evenement('Club des Merles', { texte: CONFLIT }) }, ], }); await m.seance.ouvrir('bal'); flushSync(); // La lecture de la liste que lance la fermeture lit le dossier à son // appel, et ne rend ce qu'elle a lu qu'à liberer(). const lister = m.facade.lister; let liberer; const barriere = new Promise((resoudre) => { liberer = resoudre; }); m.facade.lister = (...arguments_) => { const lue = lister(...arguments_); return barriere.then(() => lue); }; await m.seance.fermer(); await m.seance.ouvrir('club'); flushSync(); const lireTexte = m.fs.lireTexte; const etats = []; m.fs.lireTexte = (racine, chemin) => { if (chemin.endsWith('.gtt.json')) etats.push(chemin); return lireTexte.call(m.fs, racine, chemin); }; liberer(); for (let pas = 0; pas < 4; pas += 1) { await uneImage(); m.horloge.avancer(0); } expect(m.seance.etat().evenement?.base).toBe('club'); expect(etats).toEqual([]); }); }); describe('l’événement ouvert et sa navigation', () => { test('chaque ouverture remet la navigation à son départ : la section d’ouverture, montrée', async () => { const m = await avecEvenementOuvert(); expect(m.ui.etat().section).toBe(SECTION_OUVERTURE); expect(dans(m, `[data-section="${SECTION_OUVERTURE}"]`)).not.toBeNull(); dans(m, '[data-commande="section"][data-cible="tables"]').click(); flushSync(); expect(m.ui.etat().section).toBe('tables'); expect(dans(m, '[data-section="tables"]')).not.toBeNull(); dans(m, '[data-commande="liste"]').click(); await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull()); await m.seance.ouvrir('bal'); flushSync(); expect(m.ui.etat().section).toBe(SECTION_OUVERTURE); expect(dans(m, '[data-section]').dataset.section).toBe(SECTION_OUVERTURE); }); }); describe('« À propos » (§ 18.6)', () => { const panneau = (m) => dans(m, '[data-ecran="apropos"]'); test('le panneau s’ouvre depuis le bandeau sur la liste puis sur un événement ; « Copier » remet exactement le bloc affiché', async () => { const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] }); const attendu = blocAPropos({ version: VERSION, plateforme: m.fs.nature, provenance: 'test', format: FORMAT }, t); dans(m, '[data-commande="apropos"]').click(); flushSync(); expect(visible(panneau(m))).toBe(true); const bloc = dans(m, '[data-bloc]'); expect(bloc.textContent).toBe(attendu); dans(m, '[data-commande="copier"]').click(); await vi.waitFor(() => expect(m.presse.copies).toEqual([bloc.textContent])); dans(m, '[data-commande="fermerApropos"]').click(); flushSync(); expect(ecran(m).dataset.ecran).toBe('accueil'); await m.seance.ouvrir('bal'); flushSync(); expect(ecran(m).dataset.ecran).toBe('evenement'); dans(m, '[data-commande="apropos"]').click(); flushSync(); expect(visible(panneau(m))).toBe(true); expect(dans(m, '[data-bloc]').textContent).toBe(attendu); }); }); describe('thème (§ 19.2)', () => { const preferer = (valeur) => cdp().send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: valeur }] }); const themePose = () => document.documentElement.getAttribute('data-theme'); test('(a) monté sans réglage, préférence sombre du système : thème sombre', async () => { await preferer('dark'); try { expect(window.matchMedia('(prefers-color-scheme: dark)').matches).toBe(true); const m = await monter({ theme: null }); flushSync(); expect(themePose()).toBe('sombre'); expect(dans(m, '[data-commande="theme"][data-theme-choix="sombre"]').getAttribute('aria-pressed')).toBe('true'); } finally { await preferer(''); } }); test('(b) le réglage basculé à clair ne survit pas : un second montage sur le même disque repart de la préférence, et rien n’est écrit au stockage du navigateur', async () => { const ecritures = vi.spyOn(Storage.prototype, 'setItem'); await preferer('dark'); try { const premier = await monter({ theme: null }); dans(premier, '[data-commande="theme"][data-theme-choix="clair"]').click(); flushSync(); expect(themePose()).toBe('clair'); premier.demonter(); montes = []; await monter({ theme: null, systeme: { disque: premier.disque } }); flushSync(); expect(themePose()).toBe('sombre'); expect(ecritures).not.toHaveBeenCalled(); } finally { await preferer(''); } }); test('(c) basculer le réglage change les couleurs calculées du corps et du bandeau, sans rechargement', async () => { const m = await monter(); const couleurs = () => [document.body, bandeau(m)].map((el) => { const style = getComputedStyle(el); return `${style.color} sur ${style.backgroundColor}`; }); const clair = couleurs(); const boutonSombre = dans(m, '[data-commande="theme"][data-theme-choix="sombre"]'); expect(boutonSombre.getAttribute('aria-pressed')).toBe('false'); boutonSombre.click(); flushSync(); expect(themePose()).toBe('sombre'); expect(boutonSombre.getAttribute('aria-pressed')).toBe('true'); const sombre = couleurs(); expect(sombre[0]).not.toBe(clair[0]); expect(sombre[1]).not.toBe(clair[1]); }); }); describe('collecteur d’erreurs (§ 19.2)', () => { // Un cadre neuf dont la page charge le collecteur, puis le contenu donné. async function cadre(contenu) { const element = document.createElement('iframe'); const charge = new Promise((resoudre) => element.addEventListener('load', resoudre, { once: true })); element.srcdoc = `