The window showed only a banner; it now holds the application. App mounts the session, the facade and the generation, settles the working folder and lists its events before it reports ready, re-reading the list once a question is answered or a refused determination succeeds. The banner carries version, mode and the event list command; an open event shows its sections in their first form; the theme follows the system at each start and the operator's choice lasts the session; À propos names the build. Errors are collected for a report, and Electron removes the session's locks at exit. Checked: 2189 node, 193 browser, 60 node-long; the Electron start check. --- FR --- [ADD] interface : coquille — bandeau, sections, thème, À propos La fenêtre ne montrait qu'un bandeau ; elle tient désormais l'application. App monte la séance, la façade et la génération, règle le dossier de travail et liste ses événements avant de se dire prête, et relit la liste une fois une question répondue ou une détermination refusée réussie. Le bandeau porte version, mode et la commande de la liste ; un événement ouvert montre ses sections dans leur première forme ; le thème suit le système à chaque démarrage et le choix de l'opérateur vaut pour la séance ; À propos nomme la construction. Les erreurs se collectent pour un signalement, et Electron efface à la sortie les verrous de la séance. Vérifié : 2189 node, 193 navigateur, 60 node-long ; démarrage d'Electron. Assisted-by: Claude Opus 5.5
783 lines
35 KiB
JavaScript
783 lines
35 KiB
JavaScript
// © 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, 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 <html> 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 { TABLES_APPLICATION } from '../application/libelles.js';
|
||
import { creerTraduction } from '../application/traduction.js';
|
||
import { LIVREES } from '../demo/livrees.js';
|
||
import { FORMAT } 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 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('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 = `<!doctype html><html><head><script>${COLLECTEUR}</` + `script>${contenu}</head><body><button id="b"></button></body></html>`;
|
||
document.body.append(element);
|
||
await charge;
|
||
return element;
|
||
}
|
||
// Exécute du code dans la page du cadre, par un script classique inséré.
|
||
function executer(element, code) {
|
||
const doc = element.contentDocument;
|
||
const script = doc.createElement('script');
|
||
script.textContent = code;
|
||
doc.body.append(script);
|
||
}
|
||
|
||
test('une erreur à l’évaluation d’un module chargé après lui est au tableau', async () => {
|
||
const element = await cadre(`<script type="module">throw new Error('évaluation du module');</` + 'script>');
|
||
try {
|
||
const erreurs = element.contentWindow.__gttErreurs;
|
||
expect(erreurs.map(({ type }) => type)).toEqual(['error']);
|
||
expect(erreurs[0].message).toContain('évaluation du module');
|
||
} finally {
|
||
element.remove();
|
||
}
|
||
});
|
||
|
||
test('une erreur levée dans un gestionnaire, une promesse rejetée sans gestionnaire, un console.error : trois entrées, dans cet ordre', async () => {
|
||
const element = await cadre('');
|
||
try {
|
||
const erreurs = element.contentWindow.__gttErreurs;
|
||
expect(erreurs).toEqual([]);
|
||
executer(element, "document.getElementById('b').addEventListener('click', () => { throw new Error('gestionnaire'); }); document.getElementById('b').click();");
|
||
expect(erreurs.length).toBe(1);
|
||
executer(element, "Promise.reject(new Error('rejet'));");
|
||
await vi.waitFor(() => expect(erreurs.length).toBe(2));
|
||
executer(element, "console.error('console', 1);");
|
||
expect(erreurs.map(({ type }) => type)).toEqual(['error', 'unhandledrejection', 'console.error']);
|
||
expect(erreurs.map(({ message }) => message)).toEqual([
|
||
expect.stringContaining('gestionnaire'),
|
||
expect.stringContaining('rejet'),
|
||
'console 1',
|
||
]);
|
||
} finally {
|
||
element.remove();
|
||
}
|
||
});
|
||
|
||
test('sous monterApp, un console.error paraît dans erreurs()', async () => {
|
||
erreursAdmises = true;
|
||
const m = await monter();
|
||
expect(m.erreurs()).toEqual([]);
|
||
console.error('épreuve du collecteur');
|
||
expect(m.erreurs()).toEqual([{ type: 'console.error', message: 'épreuve du collecteur' }]);
|
||
});
|
||
});
|
||
|
||
describe('injection des plateformes (§ 13.4)', () => {
|
||
test('deux événements au dossier : ni OPFS ni pont, et le montage n’écrit que la sonde du dossier retenu', async () => {
|
||
const opfs = vi.spyOn(navigator.storage, 'getDirectory');
|
||
const acces = [];
|
||
const pont = new Proxy({}, {
|
||
get(_cible, cle) {
|
||
acces.push(String(cle));
|
||
return undefined;
|
||
},
|
||
});
|
||
window.gtt = pont;
|
||
try {
|
||
const m = await monter({
|
||
fichiers: [fichier('bal', 'Bal des Grisailles'), fichier('club', 'Club des Merles', { texte: CONFLIT })],
|
||
});
|
||
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2);
|
||
expect(opfs).not.toHaveBeenCalled();
|
||
expect(acces).toEqual([]);
|
||
expect(m.trace).toEqual([['sonder', 'documents', '']]);
|
||
} finally {
|
||
delete window.gtt;
|
||
}
|
||
});
|
||
|
||
test('deux montages simultanés, chacun dans son conteneur, ne se mêlent pas', async () => {
|
||
const a = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
|
||
const b = await monter({ fichiers: [fichier('club', 'Club des Merles', { texte: CONFLIT })] });
|
||
expect(a.racine).not.toBe(b.racine);
|
||
expect(a.fs).not.toBe(b.fs);
|
||
expect(a.seance).not.toBe(b.seance);
|
||
expect(a.racine.textContent).toContain('Bal des Grisailles');
|
||
expect(b.racine.textContent).toContain('Club des Merles');
|
||
expect(a.racine.textContent).not.toContain('Club des Merles');
|
||
expect(b.racine.textContent).not.toContain('Bal des Grisailles');
|
||
await a.seance.ouvrir('bal');
|
||
flushSync();
|
||
expect(libelleDeMode(a)).not.toBeNull();
|
||
expect(libelleDeMode(b)).toBeNull();
|
||
expect(b.seance.etat().evenement).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('le montage d’épreuve', () => {
|
||
test('aImporter : le dialogue rend chaque fichier, dans l’ordre, puis null ; les identifiants portent le rang du montage, distincts d’un montage à l’autre', async () => {
|
||
const premier = { nom: 'invites.csv', octets: new Uint8Array([0x6e, 0x6f, 0x6d]) };
|
||
const second = { nom: 'accueil.csv', octets: new Uint8Array([0x41]) };
|
||
const a = await monter({ aImporter: [premier, second] });
|
||
expect(await a.facade.choisirCsv()).toBe(premier);
|
||
expect(await a.facade.choisirCsv()).toBe(second);
|
||
expect(await a.facade.choisirCsv()).toBeNull();
|
||
const b = await monter();
|
||
expect(await b.facade.choisirCsv()).toBeNull();
|
||
expect(a.seance.id).toMatch(/^epreuve-\d+-\d+$/);
|
||
expect(b.seance.id).toMatch(/^epreuve-\d+-\d+$/);
|
||
expect(a.seance.id.split('-')[1]).not.toBe(b.seance.id.split('-')[1]);
|
||
});
|
||
});
|
||
|
||
describe('l’ordre du montage', () => {
|
||
test('non publié, Documents non inscriptible : pret se résout, la façade rend sa question, la séance n’a pas de racine', async () => {
|
||
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } } });
|
||
// Lu dès pret, avant tout appel de l'épreuve à la façade : la
|
||
// détermination est celle qu'App a faite.
|
||
expect(m.trace).toContainEqual(['sonder', 'documents', '']);
|
||
expect(m.seance.etat().racine).toBeNull();
|
||
expect(ecran(m)).not.toBeNull();
|
||
const { dossier } = await m.facade.demarrer();
|
||
expect(dossier.question?.code).toBe('DOCUMENTS_NON_INSCRIPTIBLE');
|
||
expect(m.seance.etat().racine).toBeNull();
|
||
});
|
||
|
||
test('sans question : dès pret, la racine de la séance est celle du dossier retenu', async () => {
|
||
const m = await monter();
|
||
// Lu dès pret, avant tout appel de l'épreuve à la façade : la racine
|
||
// est celle qu'App a fait poser.
|
||
expect(m.trace).toEqual([['sonder', 'documents', '']]);
|
||
const posee = m.seance.etat().racine;
|
||
expect(posee?.id).toBe('documents');
|
||
const { dossier } = await m.facade.demarrer();
|
||
expect(dossier.question).toBeNull();
|
||
expect(m.seance.etat().racine).toEqual(dossier.racine);
|
||
expect(m.seance.etat().racine).toBe(posee);
|
||
});
|
||
|
||
test('sans question : dès pret, le premier écran porte la liste du dossier retenu', async () => {
|
||
// Lu aussitôt après pret, sans attente : pret ne se résout qu'une fois
|
||
// les lignes de la liste rendues.
|
||
const m = await monter({
|
||
fichiers: [fichier('bal', 'Bal des Grisailles'), fichier('club', 'Club des Merles', { texte: CONFLIT })],
|
||
});
|
||
const lignes = [...m.racine.querySelectorAll('[data-evenement]')];
|
||
expect(lignes.length).toBe(2);
|
||
expect(lignes.map((ligne) => ligne.dataset.evenement).sort()).toEqual(['bal', 'club']);
|
||
expect(m.racine.textContent).toContain('Bal des Grisailles');
|
||
expect(m.racine.textContent).toContain('Club des Merles');
|
||
});
|
||
|
||
test('Documents non inscriptible : dès pret, l’accueil est rendu sans aucune ligne, la question attend', async () => {
|
||
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } }, fichiers: [fichier('bal', 'Bal des Grisailles')] });
|
||
expect(ecran(m)?.dataset.ecran).toBe('accueil');
|
||
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
|
||
expect(m.racine.querySelectorAll('[data-demonstration]').length).toBeGreaterThan(0);
|
||
// Une lecture de la liste pendant que la question attend serait refusée
|
||
// (QUESTION_EN_ATTENTE), et ce refus s'afficherait à la place des lignes.
|
||
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
|
||
});
|
||
|
||
test('Documents non inscriptible, « continuer » répondu : la liste est lue après la réponse, ses lignes rendues, sans refus', async () => {
|
||
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } }, fichiers: [fichier('bal', 'Bal des Grisailles')] });
|
||
// Chaque lecture de la liste passe par la façade de ce montage ; une
|
||
// lecture faite pendant que la question attend se voit à son refus.
|
||
const issues = [];
|
||
const lister = m.facade.lister;
|
||
vi.spyOn(m.facade, 'lister').mockImplementation(async () => {
|
||
try {
|
||
const lues = await lister();
|
||
issues.push('lue');
|
||
return lues;
|
||
} catch (erreur) {
|
||
issues.push(erreur?.code ?? 'faute');
|
||
throw erreur;
|
||
}
|
||
});
|
||
await m.facade.repondre('continuer');
|
||
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1));
|
||
expect(dans(m, '[data-evenement]').dataset.evenement).toBe('bal');
|
||
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
|
||
expect(issues).not.toContain('QUESTION_EN_ATTENTE');
|
||
expect(issues).toContain('lue');
|
||
});
|
||
});
|
||
|
||
describe('fermer sans question (§ 8.1, § 8.4, § 8.8)', () => {
|
||
test('aucun écouteur beforeunload ; au pagehide, la séance se ferme et rend son verrou', async () => {
|
||
const ecouteurs = vi.spyOn(window, 'addEventListener');
|
||
const m = await avecEvenementOuvert();
|
||
const poses = ecouteurs.mock.calls.map(([type]) => type);
|
||
expect(poses).toContain('pagehide');
|
||
expect(poses).not.toContain('beforeunload');
|
||
await m.seance.passerEnEcriture();
|
||
expect(verrouDe(m, 'bal')).not.toBeNull();
|
||
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false }));
|
||
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
|
||
expect(verrouDe(m, 'bal')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('rendu du bandeau, dans les deux thèmes (§ 14.2, § 14.5)', () => {
|
||
// Un événement ouvert en écriture : le bandeau porte alors tout ce qu'il
|
||
// peut porter — libellé de mode, « Liste des événements », thèmes, « À
|
||
// propos ».
|
||
async function bandeauComplet() {
|
||
const m = await avecEvenementOuvert();
|
||
await m.seance.passerEnEcriture();
|
||
flushSync();
|
||
expect(libelleDeMode(m)).not.toBeNull();
|
||
return m;
|
||
}
|
||
|
||
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre', async () => {
|
||
// Le texte, le titre et le style calculé se relisent sur un élément qui
|
||
// ne peint rien. visible interroge donc la mise en page : checkVisibility,
|
||
// une boîte non vide dans la fenêtre, et le centre de cette boîte qui
|
||
// atteint l'élément ou l'un de ses descendants sans qu'un élément peint
|
||
// au-dessus y peigne — un élément recouvert, ou rogné par un ancêtre,
|
||
// échoue.
|
||
const m = await bandeauComplet();
|
||
for (const theme of THEMES) {
|
||
await sousTheme(theme, () => {
|
||
const porteurs = bandeauEtTextes(m);
|
||
expect(porteurs.length).toBeGreaterThan(4);
|
||
for (const porteur of porteurs) {
|
||
expect(visible(porteur), `${theme} : ${designation(porteur)}`).toBe(true);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
test('le texte et le fond du bandeau ont une couleur calculée', async () => {
|
||
const m = await bandeauComplet();
|
||
const style = getComputedStyle(bandeau(m));
|
||
expect(style.color).not.toBe(TRANSPARENT);
|
||
expect(style.backgroundColor).not.toBe(TRANSPARENT);
|
||
});
|
||
|
||
test('chaque texte du bandeau tient 4,5:1 sur chaque couleur peinte sous lui', async () => {
|
||
const m = await bandeauComplet();
|
||
const couleursParTheme = [];
|
||
for (const theme of THEMES) {
|
||
const couleurs = await sousTheme(theme, () => {
|
||
const textes = porteursDeTexte(bandeau(m));
|
||
expect(textes.length).toBeGreaterThan(4);
|
||
for (const porteur of textes) {
|
||
const lieu = `${theme} : ${designation(porteur)}`;
|
||
const texte = couleurTexte(porteur);
|
||
const fonds = fondPeintSous(porteur);
|
||
expect(fonds.length, `${lieu} : aucun fond opaque`).toBeGreaterThan(0);
|
||
expect(texte[3], lieu).toBe(1);
|
||
for (const fond of fonds) {
|
||
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
|
||
}
|
||
}
|
||
return [document.body, bandeau(m)].map((el) => {
|
||
const style = getComputedStyle(el);
|
||
return `${composantes(style.color)} sur ${composantes(style.backgroundColor)}`;
|
||
});
|
||
});
|
||
couleursParTheme.push(couleurs);
|
||
}
|
||
// Un attribut que la feuille ne lit pas, ou un thème sombre qui ne
|
||
// redéfinit aucune couleur, ferait mesurer deux fois le thème clair.
|
||
expect(
|
||
couleursParTheme[1],
|
||
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,
|
||
).not.toEqual(couleursParTheme[0]);
|
||
});
|
||
|
||
test('aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte', async () => {
|
||
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
|
||
// le pixel peint sans changer le style calculé du texte. attenuations
|
||
// relit donc chaque niveau de l'ascendance, jusqu'à la racine du document.
|
||
const m = await bandeauComplet();
|
||
for (const theme of THEMES) {
|
||
await sousTheme(theme, () => {
|
||
const textes = porteursDeTexte(bandeau(m));
|
||
expect(textes.length).toBeGreaterThan(4);
|
||
for (const porteur of textes) {
|
||
const fautes = attenuations(porteur).map(
|
||
(f) => `${designation({ element: f.element, pseudo: f.pseudo })} ${f.propriete}: ${f.valeur}`,
|
||
);
|
||
expect(fautes, `${theme} : ${designation(porteur)}`).toEqual([]);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|