The working screens of an evening. The home screen lists the events of the working folder, asks its question, loads demos, copies, renames, imports and deletes, and shows what the trash holds. An open event frames its mode, offers Modifier, Débloquer and a confirmed lock takeover, and shows its warnings. Participants get a grid, a form and a file; an import goes through a preview and a report; tables, the default and tours have their screens; the plan draws tables, chairs, empty seats and names. Checked: 2342 node, 356 browser, 60 node-long; the Electron start check. --- FR --- [ADD] interface : liste, événement, participants, import, tables, plan Les écrans de travail d'une soirée. L'accueil liste les événements du dossier de travail, pose sa question, charge les démonstrations, copie, renomme, importe et supprime, et montre ce que tient la corbeille. Un événement ouvert encadre son mode, offre Modifier, Débloquer et une reprise de verrou confirmée, et montre ses avertissements. Les participants ont une grille, un formulaire et une fiche ; un import passe par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ; le plan dessine tables, chaises, sièges vides et noms. Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron. Assisted-by: Claude Opus 5.5
1002 lines
51 KiB
JavaScript
1002 lines
51 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// La liste des événements et le dossier de travail dans un vrai moteur de
|
||
// rendu (§ 8.1, § 8.4, § 8.5, § 8.6, § 8.7, § 8.8, § 15, § 18.6, § 19.4) :
|
||
// le premier écran, le répertoire vide, les trois issues, créer, un seul
|
||
// événement ouvert, ouvrir sans écrire, les signalements, la récupération,
|
||
// l'ouverture en lecture, les réglages illisibles, le dossier de travail, ses
|
||
// avis et ses questions, l'explorateur, l'autre emplacement, copier,
|
||
// renommer, importer, supprimer, les démonstrations, la racine neutre, et la
|
||
// liste qui défile sous le bandeau. Chaque épreuve monte App par monterApp
|
||
// (test/navigateur/montage.js), sur le système de fichiers simulé, et lit
|
||
// le relevé de ses écritures.
|
||
//
|
||
// Le test interroge le rendu et le disque, jamais une classe : le texte, la
|
||
// visibilité, l'élément qu'atteint le centre d'une boîte, les fichiers du
|
||
// disque simulé. Les éléments s'atteignent par leurs attributs data-….
|
||
// Les noms d'épreuve sont inventés.
|
||
//
|
||
// Le projet navigateur emploie expect, et prend test et describe chez Vitest
|
||
// (voir src/interface/App.navigateur.test.js).
|
||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||
import { flushSync, tick } from 'svelte';
|
||
import { creerFormat } from '../../application/format.js';
|
||
import { creerHorlogePilotee } from '../../application/horloge.js';
|
||
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
|
||
import { creerTraduction } from '../../application/traduction.js';
|
||
import { CATALOGUE } from '../../demo/catalogue.js';
|
||
import { LIVREES } from '../../demo/livrees.js';
|
||
import { serialiser } from '../../stockage/canonique.js';
|
||
import { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../../stockage/depot.js';
|
||
import { analyser, creerCharge } from '../../stockage/document.js';
|
||
import { lireJournal } from '../../stockage/journal.js';
|
||
import { SUFFIXES } from '../../stockage/noms.js';
|
||
import { libelleDeLInstant } from '../../stockage/operations.js';
|
||
import { VERSION } from '../../version.genere.js';
|
||
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
|
||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||
import { visible as visibleDansLaFenetre } from '../../../test/navigateur/rendu.js';
|
||
import { TABLES_INTERFACE } from '../libelles.js';
|
||
|
||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||
const format = creerFormat(t);
|
||
|
||
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
|
||
const PUBLIE = 'E:\\soirees';
|
||
const DATA = 'E:\\soirees\\data';
|
||
const SOUS_APPDATA = 'C:\\Users\\Exemple\\AppData\\Local\\Programmes\\Tablee';
|
||
const HORODATAGE_DEPOT = '2031-05-17T08:05:00-04:00';
|
||
// Le dossier daté de la corbeille au départ de l'horloge pilotée.
|
||
const CORBEILLE_DU_DEPART = 'corbeille/2026-10-06_19-00-00';
|
||
// Un dossier daté que la corbeille porte avant le montage, antérieur au
|
||
// départ de l'horloge pilotée.
|
||
const ANCIEN_DOSSIER = 'corbeille/2026-01-15_12-00-00';
|
||
const UTF8 = new TextEncoder();
|
||
|
||
// --- Les fichiers d'épreuve ------------------------------------------------------
|
||
|
||
// Le fichier d'état d'une démonstration livrée, renommé, au format courant.
|
||
const PETITE = LIVREES.find(({ cle }) => cle === 'petite').texte;
|
||
function texteRenomme(nom, { etat = null } = {}) {
|
||
const { charge } = analyser(PETITE);
|
||
charge.evenement.nom = nom;
|
||
if (etat !== null) charge.evenement.etat = etat;
|
||
return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
|
||
}
|
||
|
||
// Le texte d'état d'un événement neuf.
|
||
const texteNeuf = ({ id, nom }) =>
|
||
serialiser(creerCharge({ id, nom, siegesParDefaut: 8, tours: 2 }), { revision: 1, produitVersion: VERSION.affichee });
|
||
|
||
const fichier = (chemin, texte, racine = 'documents') => ({ racine, chemin, texte });
|
||
|
||
// Un disque garni par le dépôt avant le montage — un autre processus, celui
|
||
// d'une séance d'avant : chaque événement a son journal d'une entrée et son
|
||
// jalon. Rend le disque, l'instance qui l'a garni et ses racines.
|
||
async function disqueAvec(evenements, reglages = {}) {
|
||
const disque = new Map();
|
||
const fs = creerFichiersSimules({ disque, ...reglages });
|
||
const racines = await fs.racines();
|
||
for (const { racine = 'documents', id, nom, etat = null, date = null } of evenements) {
|
||
fs.deposerDossier(racine);
|
||
const charge = creerCharge({ id, nom, date, siegesParDefaut: 8, tours: 2 });
|
||
if (etat !== null) charge.evenement.etat = etat;
|
||
await creerDansLeDepot(fs, racines[racine], charge, {
|
||
libelle: libelle('ENTREE_CREATION', { nom }),
|
||
nomJalon: libelle('JALON_CREATION'),
|
||
horodatage: HORODATAGE_DEPOT,
|
||
produitVersion: VERSION.affichee,
|
||
});
|
||
}
|
||
return { disque, fs, racines };
|
||
}
|
||
|
||
// --- Le montage et ses lectures ---------------------------------------------------
|
||
|
||
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();
|
||
window.scrollTo(0, 0);
|
||
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 tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
|
||
const accueil = (m) => dans(m, '[data-ecran="accueil"]');
|
||
const lignes = (m) => tous(m, '[data-evenement]');
|
||
const ligneDe = (m, base) => dans(m, `[data-evenement="${CSS.escape(base)}"]`);
|
||
const colonne = (ligne, nom) => ligne.querySelector(`[data-colonne="${nom}"]`)?.textContent;
|
||
const ecritures = (m) => m.trace.length;
|
||
const sondes = (m) => m.trace.filter(([primitive]) => primitive === 'sonder').length;
|
||
|
||
// Visible une fois ramené dans la fenêtre : la liste défile sous le bandeau.
|
||
function visible(element) {
|
||
element.scrollIntoView({ block: 'center' });
|
||
return visibleDansLaFenetre(element);
|
||
}
|
||
|
||
// Le centre d'un élément, ramené dans la fenêtre, atteint l'élément.
|
||
function atteint(element) {
|
||
element.scrollIntoView({ block: 'center' });
|
||
const boite = element.getBoundingClientRect();
|
||
const touche = document.elementFromPoint(boite.left + boite.width / 2, boite.top + boite.height / 2);
|
||
return element === touche || element.contains(touche);
|
||
}
|
||
|
||
// Saisit un texte dans un champ, comme une frappe : la valeur, puis input.
|
||
function saisir(entree, texte) {
|
||
entree.value = texte;
|
||
entree.dispatchEvent(new Event('input', { bubbles: true }));
|
||
flushSync();
|
||
}
|
||
|
||
async function cliquer(element) {
|
||
expect(element, 'élément à cliquer').not.toBeNull();
|
||
element.click();
|
||
await tick();
|
||
}
|
||
|
||
// L'écran de l'événement ouvert, une fois rendu.
|
||
async function attendreEvenement(m, quoi = () => true) {
|
||
await vi.waitFor(() => {
|
||
const evenement = m.seance.etat().evenement;
|
||
expect(evenement).not.toBeNull();
|
||
expect(quoi(evenement)).toBe(true);
|
||
expect(dans(m, '[data-ecran="evenement"]')).not.toBeNull();
|
||
});
|
||
return m.seance.etat().evenement;
|
||
}
|
||
|
||
// « Liste des événements » du bandeau, puis la liste relue de n lignes.
|
||
async function revenirALaListe(m, n = null) {
|
||
await cliquer(dans(m, '[data-bandeau] [data-commande="liste"]'));
|
||
await vi.waitFor(() => {
|
||
expect(m.seance.etat().evenement).toBeNull();
|
||
expect(accueil(m)).not.toBeNull();
|
||
if (n !== null) expect(lignes(m).length).toBe(n);
|
||
});
|
||
}
|
||
|
||
// Ouvre la ligne base par sa commande.
|
||
async function ouvrirLigne(m, base) {
|
||
await cliquer(ligneDe(m, base).querySelector('[data-commande="ouvrir"]'));
|
||
}
|
||
|
||
// Crée par le formulaire de l'accueil.
|
||
async function creerParLeFormulaire(m, nom, date = '') {
|
||
saisir(dans(m, '[data-champ="nom"]'), nom);
|
||
saisir(dans(m, '[data-champ="date"]'), date);
|
||
await cliquer(dans(m, '[data-commande="creer"]'));
|
||
}
|
||
|
||
// Aucune fenêtre ni boîte de dialogue dans le document.
|
||
function aucuneFenetre() {
|
||
expect(document.querySelectorAll('[role=dialog], [role=alertdialog], dialog[open]').length).toBe(0);
|
||
}
|
||
|
||
// Les nœuds de texte non blancs d'un élément, dans l'ordre du document.
|
||
function noeudsDeTexte(element) {
|
||
const textes = [];
|
||
const parcours = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||
while (parcours.nextNode()) if (parcours.currentNode.textContent.trim() !== '') textes.push(parcours.currentNode.textContent);
|
||
return textes;
|
||
}
|
||
|
||
// Les textes de chaque fichier d'une racine du disque, par chemin relatif.
|
||
async function photographier(m, racine, dossier = '') {
|
||
const racines = await m.fs.racines();
|
||
const photo = {};
|
||
for (const { nom, type } of await m.fs.lister(racines[racine], dossier)) {
|
||
const chemin = dossier === '' ? nom : `${dossier}/${nom}`;
|
||
if (type === 'fichier') photo[chemin] = m.fs.contenu(racine, chemin);
|
||
}
|
||
return photo;
|
||
}
|
||
|
||
// --- Le premier écran (§ 8.1, § 18.6) ---------------------------------------------
|
||
|
||
describe('le premier écran (§ 8.1, § 18.6)', () => {
|
||
test('le logiciel démarre sur la liste, sans événement ; « version » puis la version ; deux événements, six informations égales au fichier', async () => {
|
||
const { disque, fs, racines } = await disqueAvec([
|
||
{ id: 'evt-printemps', nom: 'Gala du printemps', date: '2031-05-17' },
|
||
]);
|
||
// Renommé à la main : ses deux fichiers sous une autre base.
|
||
for (const suffixe of [SUFFIXES.etat, SUFFIXES.journal]) {
|
||
fs.deposer('documents', `soiree-a${suffixe}`, fs.contenu('documents', `Gala du printemps${suffixe}`));
|
||
await fs.supprimer(racines.documents, `Gala du printemps${suffixe}`);
|
||
}
|
||
// Sans journal.
|
||
const club = texteRenomme('Club des Merles');
|
||
fs.deposer('documents', `club${SUFFIXES.etat}`, club);
|
||
const m = await monter({ systeme: { disque } });
|
||
|
||
expect(m.seance.etat().evenement).toBeNull();
|
||
expect(accueil(m)).not.toBeNull();
|
||
const version = dans(m, '[data-version]');
|
||
expect(version.textContent.replace(/\s+/g, ' ').trim()).toBe(`${t('coquille.version.libelle')} ${VERSION.affichee}`);
|
||
expect(visible(version)).toBe(true);
|
||
|
||
expect(lignes(m).length).toBe(2);
|
||
const gala = ligneDe(m, 'soiree-a');
|
||
const lue = analyser(m.fs.contenu('documents', `soiree-a${SUFFIXES.etat}`));
|
||
expect(colonne(gala, 'nom')).toBe(lue.charge.evenement.nom);
|
||
expect(colonne(gala, 'nom')).toBe('Gala du printemps');
|
||
expect(colonne(gala, 'date')).toBe(format.date('2031-05-17'));
|
||
expect(colonne(gala, 'etat')).toBe(t('accueil.etat.brouillon'));
|
||
expect(colonne(gala, 'participants')).toBe(format.nombre(lue.entete.comptes.participants));
|
||
expect(colonne(gala, 'modification')).toBe(format.horodatage(HORODATAGE_DEPOT));
|
||
expect(colonne(gala, 'chemin')).toBe(`${DOCUMENTS}\\soiree-a${SUFFIXES.etat}`);
|
||
|
||
const ligneClub = ligneDe(m, 'club');
|
||
const lu = analyser(club);
|
||
expect(colonne(ligneClub, 'nom')).toBe('Club des Merles');
|
||
expect(colonne(ligneClub, 'date')).toBe(lu.charge.evenement.date === null ? '\u{2014}' : format.date(lu.charge.evenement.date));
|
||
expect(colonne(ligneClub, 'participants')).toBe(format.nombre(lu.entete.comptes.participants));
|
||
expect(Number(lu.entete.comptes.participants)).toBeGreaterThan(0);
|
||
expect(colonne(ligneClub, 'modification')).toBe('\u{2014}');
|
||
expect(colonne(ligneClub, 'chemin')).toBe(`${DOCUMENTS}\\club${SUFFIXES.etat}`);
|
||
for (const ligne of lignes(m)) expect(visible(ligne)).toBe(true);
|
||
});
|
||
|
||
test('répertoire vide : « version » et la version, la création, une entrée par démonstration du catalogue, atteinte ; aucun « aucun événement »', async () => {
|
||
const m = await monter();
|
||
expect(lignes(m).length).toBe(0);
|
||
expect(dans(m, '[data-version]').textContent).toContain(VERSION.affichee);
|
||
const creer = dans(m, '[data-commande="creer"]');
|
||
expect(visible(creer)).toBe(true);
|
||
expect(atteint(creer)).toBe(true);
|
||
const entrees = tous(m, '[data-demonstration]');
|
||
expect(CATALOGUE.length).toBeGreaterThan(0);
|
||
expect(entrees.length).toBe(CATALOGUE.length);
|
||
entrees.forEach((entree, rang) => {
|
||
expect(entree.textContent.trim()).toBe(CATALOGUE[rang].nom);
|
||
expect(atteint(entree)).toBe(true);
|
||
});
|
||
expect(accueil(m).textContent.toLowerCase()).not.toContain('aucun événement');
|
||
});
|
||
|
||
test('répertoire vide, construction de documentation : la marque tient lieu de version au premier écran', async () => {
|
||
const m = await monter({ provenance: 'documentation' });
|
||
const version = dans(m, '[data-version]').textContent;
|
||
expect(version).toContain(t('coquille.version.libelle'));
|
||
expect(version).toContain(t('coquille.version.documentation'));
|
||
expect(version).not.toContain(VERSION.affichee);
|
||
});
|
||
|
||
test('contre-épreuve : un catalogue d’une entrée de plus en montre une de plus', async () => {
|
||
const demonstrations = LIVREES.map(({ cle, texte }) => ({ cle, nom: CATALOGUE.find((e) => e.cle === cle).nom, texte }));
|
||
const plus = [...demonstrations, { cle: 'epreuve', nom: 'Démonstration des Pervenches', texte: PETITE }];
|
||
const m = await monter({ demonstrations: plus });
|
||
const entrees = tous(m, '[data-demonstration]');
|
||
expect(entrees.length).toBe(CATALOGUE.length + 1);
|
||
expect(entrees.at(-1).textContent.trim()).toBe('Démonstration des Pervenches');
|
||
});
|
||
|
||
test('trois issues toujours offertes : devant une liste non vide, la création et les démonstrations restent atteignables', async () => {
|
||
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
|
||
expect(lignes(m).length).toBe(1);
|
||
expect(atteint(dans(m, '[data-commande="creer"]'))).toBe(true);
|
||
const entrees = tous(m, '[data-demonstration]');
|
||
expect(entrees.length).toBe(CATALOGUE.length);
|
||
for (const entree of entrees) expect(atteint(entree)).toBe(true);
|
||
expect(atteint(ligneDe(m, 'bal').querySelector('[data-commande="ouvrir"]'))).toBe(true);
|
||
});
|
||
});
|
||
|
||
// --- Créer, ouvrir (§ 2.1, § 8.1, § 8.2, § 8.4, § 8.8) ------------------------------------
|
||
|
||
describe('créer et ouvrir', () => {
|
||
test('créer « Soirée d’essai » : le journal avant l’état, une entrée et le jalon « Création » ; ouvert en lecture ; la liste compte une ligne de plus', async () => {
|
||
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
|
||
const avant = ecritures(m);
|
||
await creerParLeFormulaire(m, 'Soirée d\u{2019}essai');
|
||
const ouvert = await attendreEvenement(m);
|
||
expect(ouvert.nom).toBe('Soirée d\u{2019}essai');
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
const ecrits = m.trace.slice(avant).map(([, , chemin]) => chemin);
|
||
const journal = ecrits.indexOf(`${ouvert.base}${SUFFIXES.journal}`);
|
||
const etat = ecrits.indexOf(`${ouvert.base}${SUFFIXES.etat}`);
|
||
expect(journal).toBeGreaterThanOrEqual(0);
|
||
expect(etat).toBeGreaterThan(journal);
|
||
const lu = lireJournal(m.fs.contenu('documents', `${ouvert.base}${SUFFIXES.journal}`));
|
||
expect(lu.entrees.length).toBe(1);
|
||
expect(lu.jalons.map(({ nom }) => nom)).toEqual([libelle('JALON_CREATION')]);
|
||
await revenirALaListe(m, 2);
|
||
expect(colonne(ligneDe(m, ouvert.base), 'nom')).toBe('Soirée d\u{2019}essai');
|
||
});
|
||
|
||
test('un nom vide : un message contre le champ, rien au relevé, aucun « Sans titre »', async () => {
|
||
const m = await monter();
|
||
const avant = ecritures(m);
|
||
await creerParLeFormulaire(m, ' ');
|
||
const champ = dans(m, '[data-champ="nom"]');
|
||
const message = await vi.waitFor(() => {
|
||
const element = dans(m, '[data-refus="NOM_REQUIS"]');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(visible(message)).toBe(true);
|
||
expect(message.textContent).toContain(t('NOM_REQUIS'));
|
||
expect(champ.getAttribute('aria-invalid')).toBe('true');
|
||
expect(message.id).not.toBe('');
|
||
expect(champ.getAttribute('aria-describedby')).toBe(message.id);
|
||
expect(ecritures(m)).toBe(avant);
|
||
expect(m.seance.etat().evenement).toBeNull();
|
||
expect([...m.disque.keys()].some((cle) => cle.toLowerCase().includes('sans titre'))).toBe(false);
|
||
aucuneFenetre();
|
||
});
|
||
|
||
test('une date hors du calendrier : le message contre le champ de la date, rien au relevé', async () => {
|
||
const m = await monter();
|
||
const avant = ecritures(m);
|
||
await creerParLeFormulaire(m, 'Bal des Grisailles', '2031-02-30');
|
||
const message = await vi.waitFor(() => {
|
||
const element = dans(m, '[data-refus="DATE_INVALIDE"]');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(message.textContent).toContain(t('DATE_INVALIDE', { date: '2031-02-30' }));
|
||
expect(dans(m, '[data-champ="date"]').getAttribute('aria-describedby')).toBe(message.id);
|
||
expect(dans(m, '[data-champ="nom"]').getAttribute('aria-invalid')).toBeNull();
|
||
expect(ecritures(m)).toBe(avant);
|
||
});
|
||
|
||
test('un seul événement ouvert : A en écriture, « Liste des événements » le ferme sans question ni verrou ; B s’ouvre en lecture', async () => {
|
||
const confirmer = vi.spyOn(window, 'confirm');
|
||
const m = await monter({
|
||
fichiers: [
|
||
fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles')),
|
||
fichier(`club${SUFFIXES.etat}`, texteNeuf({ id: 'evt-merles', nom: 'Club des Merles' })),
|
||
],
|
||
});
|
||
await ouvrirLigne(m, 'bal');
|
||
await attendreEvenement(m, ({ base }) => base === 'bal');
|
||
await m.seance.passerEnEcriture();
|
||
expect(m.fs.contenu('documents', `bal${SUFFIXES.verrou}`)).not.toBeNull();
|
||
await revenirALaListe(m, 2);
|
||
aucuneFenetre();
|
||
expect(m.fs.contenu('documents', `bal${SUFFIXES.verrou}`)).toBeNull();
|
||
await ouvrirLigne(m, 'club');
|
||
await attendreEvenement(m, ({ base }) => base === 'club');
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
expect(confirmer).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('ouvrir et lister n’écrivent rien : le relevé n’a rien gagné depuis le montage', async () => {
|
||
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
|
||
const avant = ecritures(m);
|
||
expect(lignes(m).length).toBe(1);
|
||
await ouvrirLigne(m, 'bal');
|
||
await attendreEvenement(m);
|
||
expect(ecritures(m)).toBe(avant);
|
||
await revenirALaListe(m, 1);
|
||
expect(ecritures(m)).toBe(avant);
|
||
});
|
||
|
||
test('tout s’ouvre en lecture : l’événement créé, celui rouvert après l’écriture, une démonstration, une copie ; le fichier ne porte aucun mode', async () => {
|
||
const m = await monter();
|
||
await creerParLeFormulaire(m, 'Bal des Grisailles');
|
||
const cree = await attendreEvenement(m);
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
await m.seance.passerEnEcriture();
|
||
expect(m.seance.etat().mode).toBe('ecriture');
|
||
await revenirALaListe(m, 1);
|
||
await ouvrirLigne(m, cree.base);
|
||
await attendreEvenement(m);
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
await revenirALaListe(m, 1);
|
||
await cliquer(dans(m, '[data-demonstration="petite"]'));
|
||
const demonstration = await attendreEvenement(m, ({ base }) => base !== cree.base);
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
await revenirALaListe(m, 2);
|
||
const ligne = ligneDe(m, cree.base);
|
||
await cliquer(ligne.querySelector('[data-commande="copier"]'));
|
||
saisir(ligne.querySelector('[data-champ="nomCopie"]'), 'Bal des Grisailles, variante');
|
||
await cliquer(ligne.querySelector('[data-commande="validerCopie"]'));
|
||
const copie = await attendreEvenement(m, ({ nom }) => nom === 'Bal des Grisailles, variante');
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
for (const { base } of [cree, demonstration, copie]) {
|
||
const texte = m.fs.contenu('documents', `${base}${SUFFIXES.etat}`);
|
||
expect(texte, base).not.toBeNull();
|
||
expect(texte).not.toMatch(/"mode"|lecture|ecriture/);
|
||
}
|
||
});
|
||
});
|
||
|
||
// --- Les signalements et la récupération (§ 8.1, § 8.8) ---------------------------------
|
||
|
||
describe('signalements et récupération (§ 8.1, § 8.8)', () => {
|
||
test('un état vide, un journal seul, deux états de même identifiant ; réglages, corbeille et PDF : quatre lignes signalées, de trois sortes', async () => {
|
||
const { disque, fs, racines } = await disqueAvec([{ id: 'evt-hiboux', nom: 'Soirée des Hiboux' }]);
|
||
await fs.supprimer(racines.documents, `Soirée des Hiboux${SUFFIXES.etat}`);
|
||
const merles = texteNeuf({ id: 'evt-merles', nom: 'Club des Merles' });
|
||
const m = await monter({
|
||
systeme: { disque },
|
||
fichiers: [
|
||
fichier(`Soirée vide${SUFFIXES.etat}`, ''),
|
||
fichier(`Merles A${SUFFIXES.etat}`, merles),
|
||
fichier(`Merles B${SUFFIXES.etat}`, merles),
|
||
fichier('reglages_locaux.json', '{}'),
|
||
fichier(`corbeille/2031-01-01_00-00-00/Ancienne${SUFFIXES.etat}`, merles),
|
||
fichier('planche.pdf', '%PDF'),
|
||
],
|
||
});
|
||
expect(lignes(m).map((l) => l.dataset.evenement).sort()).toEqual([
|
||
'Merles A',
|
||
'Merles B',
|
||
'Soirée des Hiboux',
|
||
'Soirée vide',
|
||
]);
|
||
const signalements = (base) => [...ligneDe(m, base).querySelectorAll('[data-signalement]')];
|
||
for (const ligne of lignes(m)) expect(signalements(ligne.dataset.evenement).length).toBe(1);
|
||
const vide = signalements('Soirée vide')[0];
|
||
expect(vide.dataset.signalement).toBe('ETAT_ILLISIBLE');
|
||
expect(vide.textContent).toContain(t('ETAT_ILLISIBLE', { base: 'Soirée vide', raison: 'VIDE', chemin: null, secours: { precedent: null, journal: null } }));
|
||
const hiboux = signalements('Soirée des Hiboux')[0];
|
||
expect(hiboux.dataset.signalement).toBe('ETAT_ILLISIBLE');
|
||
expect(hiboux.textContent).toContain(t('ETAT_ILLISIBLE', { base: 'Soirée des Hiboux', raison: 'ABSENT', chemin: null, secours: { precedent: null, journal: 1 } }));
|
||
const a = signalements('Merles A')[0];
|
||
const b = signalements('Merles B')[0];
|
||
expect(a.dataset.signalement).toBe('accueil.signalement.doublon');
|
||
// Chaque doublon nomme l'autre base dans son propre nœud de texte, à part
|
||
// de la phrase traduite.
|
||
for (const [element, autre] of [
|
||
[a, 'Merles B'],
|
||
[b, 'Merles A'],
|
||
]) {
|
||
expect(element.textContent).toContain(t('accueil.signalement.doublon', { autres: 1 }));
|
||
expect(element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`)?.textContent).toBe(autre);
|
||
const noeuds = noeudsDeTexte(element).filter((texte) => texte.includes('Merles'));
|
||
expect(noeuds).toEqual([autre]);
|
||
}
|
||
const sortes = new Set([vide.textContent, hiboux.textContent, a.dataset.signalement]);
|
||
expect(sortes.size).toBe(3);
|
||
for (const element of [vide, hiboux, a, b]) expect(visible(element)).toBe(true);
|
||
});
|
||
|
||
test('récupération : un état tronqué, .precedent et journal lisibles → un panneau nomme le fichier et la raison, offre le .precedent puis le journal, sans écrire ; .precedent ouvre en lecture ; le premier geste réécrit l’état', async () => {
|
||
const { disque, fs } = await disqueAvec([{ id: 'evt-lucioles', nom: 'Nuit des Lucioles' }]);
|
||
const base = 'Nuit des Lucioles';
|
||
const sain = fs.contenu('documents', `${base}${SUFFIXES.etat}`);
|
||
fs.deposer('documents', `${base}${SUFFIXES.precedent}`, sain);
|
||
fs.deposer('documents', `${base}${SUFFIXES.etat}`, sain.slice(0, Math.floor(sain.length / 2)));
|
||
const m = await monter({ systeme: { disque } });
|
||
const avant = ecritures(m);
|
||
await ouvrirLigne(m, base);
|
||
const panneau = await vi.waitFor(() => {
|
||
const element = dans(m, '[data-ecran="recuperation"]');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(visible(panneau)).toBe(true);
|
||
expect(panneau.textContent).toContain(`${DOCUMENTS}\\${base}${SUFFIXES.etat}`);
|
||
const raison = panneau.querySelector('[data-refus="ETAT_ILLISIBLE"]');
|
||
expect(raison).not.toBeNull();
|
||
expect(raison.textContent).toContain(base);
|
||
const offerts = [...panneau.querySelectorAll('[data-secours]')].map((e) => e.dataset.secours);
|
||
expect(offerts).toEqual(['precedent', 'journal']);
|
||
expect(panneau.querySelector('[data-secours="precedent"]').textContent).toContain(t('communs.commande.ouvrirPrecedent'));
|
||
expect(ecritures(m)).toBe(avant);
|
||
expect(m.seance.etat().evenement).toBeNull();
|
||
|
||
await cliquer(panneau.querySelector('[data-secours="precedent"] [data-commande="ouvrirPrecedent"]'));
|
||
await attendreEvenement(m, (evenement) => evenement.base === base);
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
expect(ecritures(m)).toBe(avant);
|
||
|
||
await m.seance.passerEnEcriture();
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } });
|
||
const reecrit = analyser(m.fs.contenu('documents', `${base}${SUFFIXES.etat}`));
|
||
expect(reecrit.charge.participants.some(({ nom }) => nom === 'Pervenche')).toBe(true);
|
||
});
|
||
|
||
test('sans aucun secours lisible : un message, aucune commande d’ouverture, et aucun événement n’est créé', async () => {
|
||
const m = await monter({ fichiers: [fichier(`Soirée vide${SUFFIXES.etat}`, '')] });
|
||
const cles = [...m.disque.keys()].sort();
|
||
const avant = ecritures(m);
|
||
await ouvrirLigne(m, 'Soirée vide');
|
||
const panneau = await vi.waitFor(() => {
|
||
const element = dans(m, '[data-ecran="recuperation"]');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(panneau.textContent).toContain(t('accueil.recuperation.aucunSecours'));
|
||
expect(panneau.querySelectorAll('[data-secours]').length).toBe(0);
|
||
expect(ecritures(m)).toBe(avant);
|
||
expect([...m.disque.keys()].sort()).toEqual(cles);
|
||
expect(m.seance.etat().evenement).toBeNull();
|
||
await cliquer(panneau.querySelector('[data-commande="retour"]'));
|
||
await vi.waitFor(() => expect(accueil(m)).not.toBeNull());
|
||
expect(lignes(m).length).toBe(1);
|
||
});
|
||
});
|
||
|
||
// --- Le dossier de travail (§ 8.5, § 8.6) -------------------------------------------------
|
||
|
||
describe('le dossier de travail (§ 8.5, § 8.6)', () => {
|
||
test('réglages locaux illisibles : l’avis REGLAGES_ILLISIBLES visible, la liste utilisable', async () => {
|
||
const m = await monter({
|
||
fichiers: [fichier('reglages_locaux.json', 'pas du JSON'), fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))],
|
||
});
|
||
const avis = dans(m, '[data-avertissement="REGLAGES_ILLISIBLES"]');
|
||
expect(avis).not.toBeNull();
|
||
expect(visible(avis)).toBe(true);
|
||
const { avis: attendus } = await m.facade.demarrer();
|
||
const reglages = attendus.find(({ code }) => code === 'REGLAGES_ILLISIBLES');
|
||
expect(avis.textContent).toBe(t(reglages.code, reglages.details));
|
||
aucuneFenetre();
|
||
expect(atteint(ligneDe(m, 'bal').querySelector('[data-commande="ouvrir"]'))).toBe(true);
|
||
await ouvrirLigne(m, 'bal');
|
||
await attendreEvenement(m);
|
||
});
|
||
|
||
test('publié, sonde réussie : la liste affiche le dossier data en entier ; non publié : celui des Documents', async () => {
|
||
const portable = await monter({ systeme: { executable: PUBLIE } });
|
||
expect(dans(portable, '[data-dossier-travail]').textContent).toBe(DATA);
|
||
expect(accueil(portable).textContent).not.toContain(DOCUMENTS);
|
||
const documents = await monter();
|
||
expect(dans(documents, '[data-dossier-travail]').textContent).toBe(DOCUMENTS);
|
||
});
|
||
|
||
test('la règle ne s’applique qu’une fois : ouvrir, fermer, revenir à la liste n’ajoutent aucune sonde', async () => {
|
||
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
|
||
const avant = sondes(m);
|
||
expect(avant).toBe(1);
|
||
await ouvrirLigne(m, 'bal');
|
||
await attendreEvenement(m);
|
||
await revenirALaListe(m, 1);
|
||
await ouvrirLigne(m, 'bal');
|
||
await attendreEvenement(m);
|
||
await revenirALaListe(m, 1);
|
||
expect(sondes(m)).toBe(avant);
|
||
});
|
||
|
||
const REPLIS = [
|
||
['NON_PUBLIE', {}, {}],
|
||
['DONNEES_APPLICATIVES', { executable: SOUS_APPDATA }, {}],
|
||
['SONDE_ECHOUEE', { executable: PUBLIE, sonde: { portable: 'EACCES' } }, { cause: 'EACCES' }],
|
||
];
|
||
for (const [code, systeme, details] of REPLIS) {
|
||
test(`repli en Documents, ${code} : un avis non modal, une fois, la liste utilisable dessous`, async () => {
|
||
const m = await monter({ systeme, fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
|
||
const avis = tous(m, `[data-avertissement="${code}"]`);
|
||
expect(avis.length).toBe(1);
|
||
expect(visible(avis[0])).toBe(true);
|
||
expect(avis[0].textContent).toBe(t(code, details));
|
||
aucuneFenetre();
|
||
expect(atteint(ligneDe(m, 'bal').querySelector('[data-commande="ouvrir"]'))).toBe(true);
|
||
expect(atteint(dans(m, '[data-commande="creer"]'))).toBe(true);
|
||
await ouvrirLigne(m, 'bal');
|
||
await attendreEvenement(m);
|
||
await revenirALaListe(m, 1);
|
||
expect(tous(m, `[data-avertissement="${code}"]`).length).toBe(0);
|
||
});
|
||
}
|
||
|
||
test('plateforme web : l’avis PLATEFORME_WEB ; support amovible : l’avis SUPPORT_AMOVIBLE', async () => {
|
||
const web = await monter({ systeme: { nature: 'web' } });
|
||
expect(visible(dans(web, '[data-avertissement="PLATEFORME_WEB"]'))).toBe(true);
|
||
const amovible = await monter({ systeme: { support: { documents: 'amovible' } } });
|
||
const avis = dans(amovible, '[data-avertissement="SUPPORT_AMOVIBLE"]');
|
||
expect(visible(avis)).toBe(true);
|
||
expect(avis.textContent).toBe(t('SUPPORT_AMOVIBLE', {}));
|
||
});
|
||
|
||
test('explorateur : la commande appelle ouvrirDansExplorateur avec la racine ; sous web, absente, et une phrase dit pourquoi', async () => {
|
||
const m = await monter();
|
||
const espion = vi.fn(async () => undefined);
|
||
m.fs.ouvrirDansExplorateur = espion;
|
||
await cliquer(dans(m, '[data-commande="explorateur"]'));
|
||
await vi.waitFor(() => expect(espion).toHaveBeenCalledTimes(1));
|
||
expect(espion.mock.calls[0][0]).toEqual(m.seance.etat().racine);
|
||
|
||
const web = await monter({ systeme: { nature: 'web' } });
|
||
expect(dans(web, '[data-commande="explorateur"]')).toBeNull();
|
||
const phrase = dans(web, '[data-avertissement="EXPLORATEUR_ABSENT"]');
|
||
expect(phrase?.textContent).toBe(t('EXPLORATEUR_ABSENT'));
|
||
expect(visible(phrase)).toBe(true);
|
||
expect(web.erreurs()).toEqual([]);
|
||
});
|
||
|
||
test('data/ porte deux événements et refuse l’écriture : la question nomme le dossier et la cause, dit « 2 », offre ses réponses ; aucune liste des Documents', async () => {
|
||
const m = await monter({
|
||
systeme: { executable: PUBLIE, sonde: { portable: 'EACCES' } },
|
||
fichiers: [
|
||
fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'), 'portable'),
|
||
fichier(`club${SUFFIXES.etat}`, texteNeuf({ id: 'evt-merles', nom: 'Club des Merles' }), 'portable'),
|
||
],
|
||
});
|
||
const question = dans(m, '[data-question="PORTABLE_NON_INSCRIPTIBLE"]');
|
||
expect(question).not.toBeNull();
|
||
expect(visible(question)).toBe(true);
|
||
expect(question.textContent).toContain(DATA);
|
||
expect(question.textContent).toContain('EACCES');
|
||
expect(question.textContent).toContain('2');
|
||
expect(visible(question.querySelector('[data-commande="reessayer"]'))).toBe(true);
|
||
expect(visible(question.querySelector('[data-commande="documents"]'))).toBe(true);
|
||
expect(lignes(m).length).toBe(0);
|
||
expect(accueil(m).textContent).not.toContain(DOCUMENTS);
|
||
aucuneFenetre();
|
||
// Répondre « Documents » : les événements y sont copiés, la liste paraît.
|
||
await cliquer(question.querySelector('[data-commande="documents"]'));
|
||
await vi.waitFor(() => expect(lignes(m).length).toBe(2));
|
||
expect(dans(m, '[data-question]')).toBeNull();
|
||
expect(dans(m, '[data-dossier-travail]').textContent).toBe(DOCUMENTS);
|
||
});
|
||
|
||
test('non publié, Documents non inscriptibles : la question avant toute liste ; « Réessayer », la panne tenant, la repose', async () => {
|
||
const m = await monter({
|
||
systeme: { sonde: { documents: 'EROFS' } },
|
||
fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))],
|
||
});
|
||
const question = dans(m, '[data-question="DOCUMENTS_NON_INSCRIPTIBLE"]');
|
||
expect(visible(question)).toBe(true);
|
||
expect(question.textContent).toContain(DOCUMENTS);
|
||
expect(question.textContent).toContain('EROFS');
|
||
expect(lignes(m).length).toBe(0);
|
||
const avant = sondes(m);
|
||
await cliquer(question.querySelector('[data-commande="reessayer"]'));
|
||
await vi.waitFor(() => expect(sondes(m)).toBe(avant + 1));
|
||
// La question reposée offre de nouveau ses réponses.
|
||
await vi.waitFor(() =>
|
||
expect(dans(m, '[data-question="DOCUMENTS_NON_INSCRIPTIBLE"] [data-commande="continuer"]')).not.toBeNull(),
|
||
);
|
||
expect(lignes(m).length).toBe(0);
|
||
// « Continuer » : la liste s'ouvre telle qu'elle se lit.
|
||
await cliquer(dans(m, '[data-question] [data-commande="continuer"]'));
|
||
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
|
||
expect(dans(m, '[data-question]')).toBeNull();
|
||
});
|
||
|
||
test('l’autre emplacement : la proposition en tête, son chemin, son compte, la phrase sur les originaux ; importer copie, originaux intacts', async () => {
|
||
const reglages = { executable: SOUS_APPDATA };
|
||
const { disque } = await disqueAvec(
|
||
[
|
||
{ racine: 'portable', id: 'evt-lucioles', nom: 'Nuit des Lucioles' },
|
||
{ racine: 'portable', id: 'evt-cerfs', nom: 'Soirée des Cerfs' },
|
||
],
|
||
reglages,
|
||
);
|
||
const m = await monter({ systeme: { disque, ...reglages } });
|
||
const proposition = dans(m, '[data-autre]');
|
||
expect(visible(proposition)).toBe(true);
|
||
const cheminAutre = `${SOUS_APPDATA}\\data`;
|
||
expect(proposition.textContent).toContain(t('accueil.autre.proposition', { evenements: 2 }));
|
||
// Le chemin est une donnée : son propre nœud de texte, à part de la phrase.
|
||
expect(dans(m, '[data-autre-chemin]')?.textContent).toBe(cheminAutre);
|
||
expect(noeudsDeTexte(proposition).filter((texte) => texte.includes('\\'))).toEqual([cheminAutre]);
|
||
expect(proposition.textContent).toContain(t('accueil.autre.originaux'));
|
||
// En tête : avant la création, les démonstrations et toute ligne.
|
||
for (const suivant of [dans(m, '[data-commande="creer"]'), dans(m, '[data-demonstration]')]) {
|
||
expect(proposition.compareDocumentPosition(suivant) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||
}
|
||
const originaux = await photographier(m, 'portable');
|
||
expect(Object.keys(originaux).length).toBe(4);
|
||
await cliquer(proposition.querySelector('[data-commande="importerAutre"]'));
|
||
await vi.waitFor(() => expect(lignes(m).length).toBe(2));
|
||
expect(await photographier(m, 'portable')).toEqual(originaux);
|
||
expect(lignes(m).map((l) => colonne(l, 'nom')).sort()).toEqual(['Nuit des Lucioles', 'Soirée des Cerfs']);
|
||
// Des paires copiées : chaque état importé garde son journal, octet pour
|
||
// octet, et sa dernière modification est celle du journal d'origine.
|
||
const journaux = (photo) =>
|
||
Object.entries(photo)
|
||
.filter(([chemin]) => chemin.endsWith(SUFFIXES.journal))
|
||
.map(([, texte]) => texte)
|
||
.sort();
|
||
expect(journaux(originaux).length).toBe(2);
|
||
expect(journaux(await photographier(m, 'documents'))).toEqual(journaux(originaux));
|
||
for (const ligne of lignes(m)) expect(colonne(ligne, 'modification')).toBe(format.horodatage(HORODATAGE_DEPOT));
|
||
});
|
||
});
|
||
|
||
// --- Copier, renommer, importer, supprimer (§ 8.7) ----------------------------------------
|
||
|
||
describe('les opérations de fichiers (§ 8.7)', () => {
|
||
test('copier un plan bloqué : un champ de nom, rien avant validation ; une ligne de plus, une entrée au libellé exact, un état non bloqué, l’original intact', async () => {
|
||
const { disque } = await disqueAvec([{ id: 'evt-grisailles', nom: 'Bal des Grisailles', etat: 'bloque' }]);
|
||
const m = await monter({ systeme: { disque } });
|
||
const original = await photographier(m, 'documents');
|
||
const avant = ecritures(m);
|
||
const ligne = ligneDe(m, 'Bal des Grisailles');
|
||
expect(colonne(ligne, 'etat')).toBe(t('accueil.etat.bloque'));
|
||
await cliquer(ligne.querySelector('[data-commande="copier"]'));
|
||
const champ = ligne.querySelector('[data-champ="nomCopie"]');
|
||
expect(visible(champ)).toBe(true);
|
||
saisir(champ, 'Bal des Grisailles, variante');
|
||
expect(ecritures(m)).toBe(avant);
|
||
await cliquer(ligne.querySelector('[data-commande="validerCopie"]'));
|
||
const copie = await attendreEvenement(m, ({ nom }) => nom === 'Bal des Grisailles, variante');
|
||
await revenirALaListe(m, 2);
|
||
const racines = await m.fs.racines();
|
||
const source = await ouvrirDansLeDepot(m.fs, racines.documents, 'Bal des Grisailles');
|
||
const attendu = libelle('ENTREE_COPIE', {
|
||
source: 'Bal des Grisailles',
|
||
instant: libelleDeLInstant(source, libelle('ENTREE_REPRISE')),
|
||
});
|
||
const journal = lireJournal(m.fs.contenu('documents', `${copie.base}${SUFFIXES.journal}`));
|
||
expect(journal.entrees.map(({ libelle: texte }) => texte)).toEqual([attendu]);
|
||
expect(analyser(m.fs.contenu('documents', `${copie.base}${SUFFIXES.etat}`)).charge.evenement.etat).not.toBe('bloque');
|
||
expect(colonne(ligneDe(m, copie.base), 'etat')).not.toBe(t('accueil.etat.bloque'));
|
||
const apres = await photographier(m, 'documents');
|
||
for (const [chemin, texte] of Object.entries(original)) expect(apres[chemin]).toBe(texte);
|
||
});
|
||
|
||
test('copier sous un nom vide : refusé contre le champ, aucune copie, aucun « (copie) »', async () => {
|
||
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
|
||
const avant = ecritures(m);
|
||
const ligne = ligneDe(m, 'bal');
|
||
await cliquer(ligne.querySelector('[data-commande="copier"]'));
|
||
saisir(ligne.querySelector('[data-champ="nomCopie"]'), ' ');
|
||
await cliquer(ligne.querySelector('[data-commande="validerCopie"]'));
|
||
await vi.waitFor(() => expect(ligne.querySelector('[data-refus="NOM_REQUIS"]')).not.toBeNull());
|
||
expect(ligne.querySelector('[data-champ="nomCopie"]').getAttribute('aria-invalid')).toBe('true');
|
||
expect(ecritures(m)).toBe(avant);
|
||
expect([...m.disque.keys()].some((cle) => cle.includes('(COPIE)'))).toBe(false);
|
||
});
|
||
|
||
test('renommer : le nouveau nom et le nouveau chemin dans la liste', async () => {
|
||
const { disque } = await disqueAvec([{ id: 'evt-pervenches', nom: 'Gala des Pervenches' }]);
|
||
const m = await monter({ systeme: { disque } });
|
||
const ligne = ligneDe(m, 'Gala des Pervenches');
|
||
await cliquer(ligne.querySelector('[data-commande="renommer"]'));
|
||
const champ = ligne.querySelector('[data-champ="nouveauNom"]');
|
||
expect(champ.value).toBe('Gala des Pervenches');
|
||
saisir(champ, 'Gala des Ombrelles');
|
||
await cliquer(ligne.querySelector('[data-commande="validerRenommage"]'));
|
||
await vi.waitFor(() => expect(ligneDe(m, 'Gala des Ombrelles')).not.toBeNull());
|
||
expect(lignes(m).length).toBe(1);
|
||
const renomme = ligneDe(m, 'Gala des Ombrelles');
|
||
expect(colonne(renomme, 'nom')).toBe('Gala des Ombrelles');
|
||
expect(colonne(renomme, 'chemin')).toBe(`${DOCUMENTS}\\Gala des Ombrelles${SUFFIXES.etat}`);
|
||
expect(m.fs.contenu('documents', `Gala des Pervenches${SUFFIXES.verrou}`)).toBeNull();
|
||
});
|
||
|
||
// Coupe le processus du montage juste après l'écriture réussie de chemin.
|
||
function couperApres(m, chemin) {
|
||
for (const primitive of ['ecrireAtomique', 'ajouterLigne']) {
|
||
const originale = m.fs[primitive];
|
||
m.fs[primitive] = async (racine, cible, ...reste) => {
|
||
const rendu = await Reflect.apply(originale, m.fs, [racine, cible, ...reste]);
|
||
if (cible === chemin) m.fs.pannes.couperApres(0);
|
||
return rendu;
|
||
};
|
||
}
|
||
}
|
||
|
||
async function renommerCoupe(chemin) {
|
||
erreursAdmises = true;
|
||
const { disque } = await disqueAvec([{ id: 'evt-pervenches', nom: 'Gala des Pervenches' }]);
|
||
const m = await monter({ systeme: { disque } });
|
||
couperApres(m, chemin);
|
||
const ligne = ligneDe(m, 'Gala des Pervenches');
|
||
await cliquer(ligne.querySelector('[data-commande="renommer"]'));
|
||
saisir(ligne.querySelector('[data-champ="nouveauNom"]'), 'Gala des Ombrelles');
|
||
await cliquer(ligne.querySelector('[data-commande="validerRenommage"]'));
|
||
await vi.waitFor(() => expect(m.erreurs().length).toBeGreaterThan(0));
|
||
// Réouverture : un second processus sur le même disque.
|
||
return monter({ systeme: { disque } });
|
||
}
|
||
|
||
test('renommer coupé après le nouvel état : à la réouverture, deux lignes signalées doublons l’une de l’autre', async () => {
|
||
const rouvert = await renommerCoupe(`Gala des Ombrelles${SUFFIXES.etat}`);
|
||
expect(lignes(rouvert).map((l) => l.dataset.evenement).sort()).toEqual(['Gala des Ombrelles', 'Gala des Pervenches']);
|
||
const signalement = (base) => ligneDe(rouvert, base).querySelector('[data-signalement="accueil.signalement.doublon"]');
|
||
for (const [base, autre] of [
|
||
['Gala des Ombrelles', 'Gala des Pervenches'],
|
||
['Gala des Pervenches', 'Gala des Ombrelles'],
|
||
]) {
|
||
expect(signalement(base).textContent).toContain(t('accueil.signalement.doublon', { autres: 1 }));
|
||
expect(noeudsDeTexte(signalement(base)).filter((texte) => texte.includes('Gala'))).toEqual([autre]);
|
||
}
|
||
});
|
||
|
||
test('renommer coupé après le seul nouveau journal : à la réouverture, une ligne, l’ancienne', async () => {
|
||
const rouvert = await renommerCoupe(`Gala des Ombrelles${SUFFIXES.journal}`);
|
||
expect(lignes(rouvert).map((l) => l.dataset.evenement)).toEqual(['Gala des Pervenches']);
|
||
expect(ligneDe(rouvert, 'Gala des Pervenches').querySelectorAll('[data-signalement]').length).toBe(0);
|
||
});
|
||
|
||
test('importer un événement : une ligne de plus, octets identiques ; une seconde fois, un refus qui nomme la base ; le libellé nomme l’événement, pas les participants', async () => {
|
||
const octets = UTF8.encode(texteRenomme('Bal des Grisailles'));
|
||
const m = await monter({
|
||
aImporter: [
|
||
{ nom: `Bal des Grisailles${SUFFIXES.etat}`, octets },
|
||
{ nom: `Bal des Grisailles${SUFFIXES.etat}`, octets },
|
||
],
|
||
});
|
||
const commande = dans(m, '[data-commande="importer"]');
|
||
expect(commande.textContent).toContain(t('accueil.operation.importer'));
|
||
expect(commande.textContent.toLowerCase()).toContain('événement');
|
||
expect(commande.textContent.toLowerCase()).not.toContain('participants');
|
||
await cliquer(commande);
|
||
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
|
||
const base = lignes(m)[0].dataset.evenement;
|
||
expect([...m.fs.octets('documents', `${base}${SUFFIXES.etat}`)]).toEqual([...octets]);
|
||
await cliquer(dans(m, '[data-commande="importer"]'));
|
||
const refus = await vi.waitFor(() => {
|
||
const element = dans(m, '[data-refus="IDENTIFIANT_PRESENT"]');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(refus.textContent).toContain(base);
|
||
expect(visible(refus)).toBe(true);
|
||
expect(lignes(m).length).toBe(1);
|
||
});
|
||
|
||
test('supprimer : à confirmer en place ; la ligne part, la corbeille montre son dossier daté et ses fichiers, à côté de ceux qu’elle portait', async () => {
|
||
const m = await monter({
|
||
fichiers: [
|
||
fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles')),
|
||
fichier(`${ANCIEN_DOSSIER}/Ancienne${SUFFIXES.etat}`, texteNeuf({ id: 'evt-cerfs', nom: 'Soirée des Cerfs' })),
|
||
fichier(`${ANCIEN_DOSSIER}/Ancienne${SUFFIXES.journal}`, ''),
|
||
],
|
||
});
|
||
await vi.waitFor(() => expect(dans(m, `[data-corbeille-dossier="${ANCIEN_DOSSIER}"]`)).not.toBeNull());
|
||
const avant = ecritures(m);
|
||
const commande = () => ligneDe(m, 'bal')?.querySelector('[data-commande="supprimer"]') ?? null;
|
||
await cliquer(commande());
|
||
expect(commande().textContent).toBe(t('accueil.operation.confirmerSuppression'));
|
||
expect(ecritures(m)).toBe(avant);
|
||
aucuneFenetre();
|
||
await cliquer(commande());
|
||
await vi.waitFor(() => expect(lignes(m).length).toBe(0));
|
||
const corbeille = await vi.waitFor(() => {
|
||
const element = dans(m, `[data-corbeille-dossier="${CORBEILLE_DU_DEPART}"]`);
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(visible(corbeille)).toBe(true);
|
||
expect(corbeille.textContent).toContain(`${DOCUMENTS}\\corbeille\\2026-10-06_19-00-00`);
|
||
expect(corbeille.textContent).toContain(`bal${SUFFIXES.etat}`);
|
||
expect(m.fs.contenu('documents', `${CORBEILLE_DU_DEPART}/bal${SUFFIXES.etat}`)).not.toBeNull();
|
||
// Rien ne la vide : le dossier daté qu'elle portait reste montré, avec ses
|
||
// fichiers, et elle n'offre aucune commande.
|
||
const dossiers = tous(m, '[data-corbeille-dossier]');
|
||
expect(dossiers.map((element) => element.dataset.corbeilleDossier).sort()).toEqual([ANCIEN_DOSSIER, CORBEILLE_DU_DEPART]);
|
||
const ancien = dans(m, `[data-corbeille-dossier="${ANCIEN_DOSSIER}"]`);
|
||
expect(visible(ancien)).toBe(true);
|
||
for (const nom of [`Ancienne${SUFFIXES.etat}`, `Ancienne${SUFFIXES.journal}`]) {
|
||
expect(noeudsDeTexte(ancien)).toContain(nom);
|
||
}
|
||
expect(noeudsDeTexte(corbeille)).toContain(`bal${SUFFIXES.etat}`);
|
||
expect(dans(m, '[data-corbeille]').querySelectorAll('button, [data-commande]').length).toBe(0);
|
||
expect(m.fs.contenu('documents', `${ANCIEN_DOSSIER}/Ancienne${SUFFIXES.etat}`)).not.toBeNull();
|
||
});
|
||
|
||
test('une corbeille déjà présente au lancement se montre, et encore après un aller-retour vers un événement', async () => {
|
||
const m = await monter({
|
||
fichiers: [
|
||
fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles')),
|
||
fichier(`${ANCIEN_DOSSIER}/Ancienne${SUFFIXES.etat}`, texteNeuf({ id: 'evt-cerfs', nom: 'Soirée des Cerfs' })),
|
||
],
|
||
});
|
||
const montree = () => {
|
||
const element = dans(m, `[data-corbeille-dossier="${ANCIEN_DOSSIER}"]`);
|
||
expect(element).not.toBeNull();
|
||
expect(visible(element)).toBe(true);
|
||
expect(noeudsDeTexte(element)).toContain(`Ancienne${SUFFIXES.etat}`);
|
||
};
|
||
await vi.waitFor(montree);
|
||
await ouvrirLigne(m, 'bal');
|
||
await attendreEvenement(m);
|
||
expect(dans(m, '[data-corbeille]')).toBeNull();
|
||
await revenirALaListe(m, 1);
|
||
await vi.waitFor(montree);
|
||
});
|
||
|
||
test('supprimer un événement qu’une séance vivante tient en écriture : refusé, la séance nommée, fichiers en place', async () => {
|
||
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
|
||
const second = await monter({ systeme: { disque: m.disque } });
|
||
await second.seance.ouvrir('bal');
|
||
await second.seance.passerEnEcriture();
|
||
const commande = ligneDe(m, 'bal').querySelector('[data-commande="supprimer"]');
|
||
await cliquer(commande);
|
||
await cliquer(ligneDe(m, 'bal').querySelector('[data-commande="supprimer"]'));
|
||
const refus = await vi.waitFor(() => {
|
||
const element = ligneDe(m, 'bal').querySelector('[data-refus="VERROU_PRIS"]');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(visible(refus)).toBe(true);
|
||
// Le texte d'une séance vivante, sans son instant : celui-ci vient de
|
||
// l'horloge du disque.
|
||
const vivante = t('VERROU_PRIS', { seance: null, depuis: null, vivant: true }).split(' : ')[0];
|
||
expect(refus.textContent).toContain(vivante);
|
||
expect(m.fs.contenu('documents', `bal${SUFFIXES.etat}`)).not.toBeNull();
|
||
expect(lignes(m).length).toBe(1);
|
||
});
|
||
|
||
test('supprimer un événement passé en écriture, après « Liste des événements » : il part, sans VERROU_PRIS', async () => {
|
||
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
|
||
await ouvrirLigne(m, 'bal');
|
||
await attendreEvenement(m);
|
||
await m.seance.passerEnEcriture();
|
||
await revenirALaListe(m, 1);
|
||
await cliquer(ligneDe(m, 'bal').querySelector('[data-commande="supprimer"]'));
|
||
await cliquer(ligneDe(m, 'bal').querySelector('[data-commande="supprimer"]'));
|
||
await vi.waitFor(() => expect(lignes(m).length).toBe(0));
|
||
expect(dans(m, '[data-refus="VERROU_PRIS"]')).toBeNull();
|
||
expect(m.fs.contenu('documents', `bal${SUFFIXES.etat}`)).toBeNull();
|
||
});
|
||
});
|
||
|
||
// --- Les démonstrations (§ 15) -------------------------------------------------------------
|
||
|
||
describe('les démonstrations (§ 15)', () => {
|
||
test('deux chargements : « Petite démonstration 1 » puis « … 2 », en lecture, la première intacte', async () => {
|
||
const m = await monter();
|
||
const nom = CATALOGUE.find(({ cle }) => cle === 'petite').nom;
|
||
await cliquer(dans(m, '[data-demonstration="petite"]'));
|
||
const premiere = await attendreEvenement(m, (e) => e.nom === `${nom} 1`);
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
await revenirALaListe(m, 1);
|
||
const photo = await photographier(m, 'documents');
|
||
const fichiersPremiere = Object.keys(photo).filter((chemin) => chemin.startsWith(premiere.base));
|
||
expect(fichiersPremiere.length).toBeGreaterThan(0);
|
||
await cliquer(dans(m, '[data-demonstration="petite"]'));
|
||
await attendreEvenement(m, (e) => e.nom === `${nom} 2`);
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
const apres = await photographier(m, 'documents');
|
||
for (const chemin of fichiersPremiere) expect(apres[chemin]).toBe(photo[chemin]);
|
||
});
|
||
});
|
||
|
||
// --- La racine neutre et la liste qui défile (§ 18.6, § 19.4) ----------------------------------
|
||
|
||
describe('la racine neutre et la liste qui défile (§ 18.6, § 19.4)', () => {
|
||
const RACINE = 'C:\\Epreuve\\Tablees';
|
||
|
||
async function listeSousRacine() {
|
||
const m = await monter({ systeme: { documents: RACINE }, horloge: creerHorlogePilotee() });
|
||
await creerParLeFormulaire(m, 'Bal des Grisailles');
|
||
await attendreEvenement(m);
|
||
await revenirALaListe(m, 1);
|
||
return m;
|
||
}
|
||
|
||
test('chaque chemin de la liste est sous la racine d’épreuve ; deux montages au même départ rendent le même texte', async () => {
|
||
const premier = await listeSousRacine();
|
||
const textes = [];
|
||
const parcours = document.createTreeWalker(accueil(premier), NodeFilter.SHOW_TEXT);
|
||
while (parcours.nextNode()) textes.push(parcours.currentNode.textContent);
|
||
const chemins = textes.filter((texte) => texte.includes('\\'));
|
||
expect(chemins.length).toBeGreaterThan(0);
|
||
for (const chemin of chemins) expect(chemin.startsWith(RACINE), chemin).toBe(true);
|
||
const texte = accueil(premier).textContent;
|
||
premier.demonter();
|
||
montes = montes.filter((m) => m !== premier);
|
||
const second = await listeSousRacine();
|
||
expect(accueil(second).textContent).toBe(texte);
|
||
});
|
||
|
||
test('deux cents événements, la liste défilée jusqu’en bas : le bandeau reste dans la fenêtre, son centre l’atteint', async () => {
|
||
const fichiers = Array.from({ length: 200 }, (_, rang) =>
|
||
fichier(`soiree-${String(rang).padStart(3, '0')}${SUFFIXES.etat}`, texteNeuf({ id: `evt-${rang}`, nom: `Soirée des Cerfs ${rang}` })),
|
||
);
|
||
const m = await monter({ fichiers });
|
||
expect(lignes(m).length).toBe(200);
|
||
window.scrollTo(0, document.documentElement.scrollHeight);
|
||
expect(window.scrollY).toBeGreaterThan(0);
|
||
const bandeau = dans(m, '[data-bandeau]');
|
||
const boite = bandeau.getBoundingClientRect();
|
||
expect(boite.top).toBeGreaterThanOrEqual(0);
|
||
expect(boite.bottom).toBeLessThanOrEqual(window.innerHeight);
|
||
const touche = document.elementFromPoint(boite.left + boite.width / 2, boite.top + boite.height / 2);
|
||
expect(bandeau.contains(touche)).toBe(true);
|
||
});
|
||
});
|