Compare commits
11 commits
abae4fa9b8
...
811cd16b76
| Author | SHA1 | Date | |
|---|---|---|---|
| 811cd16b76 | |||
| 7f3dad24af | |||
| 626159db5f | |||
| ad19131954 | |||
| d19394957f | |||
| 682e69876b | |||
| 134e7850b7 | |||
| 5fbbab6477 | |||
| 876ea0f2ce | |||
| cc6b9fe996 | |||
| 964532fa19 |
171 changed files with 37051 additions and 204 deletions
|
|
@ -337,7 +337,8 @@ export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0
|
|||
* @param {number} [reglages.pid]
|
||||
* @param {string} [reglages.hote]
|
||||
* @param {() => Date} [reglages.maintenant]
|
||||
* @returns {Object<string, Function>} les primitives, par nom
|
||||
* @returns {Object<string, Function>} les primitives, par nom, et
|
||||
* verrousTenus(), qui n'en est pas une
|
||||
*/
|
||||
export function creerFichiers({
|
||||
emplacements,
|
||||
|
|
@ -364,6 +365,9 @@ export function creerFichiers({
|
|||
const demarrageDe = demarrage ?? lireDemarrage;
|
||||
// Le démarrage du processus courant, lu une fois qu'il est connu.
|
||||
let demarrageCourant = null;
|
||||
// Les verrous que verrouiller a pris et que deverrouiller n'a pas rendus :
|
||||
// chemin absolu → séance, que verrousTenus rend au processus principal.
|
||||
const tenus = new Map();
|
||||
|
||||
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
|
||||
// et sa cible en absolu, et le dossier que nomme un échec — celui qui porte
|
||||
|
|
@ -857,6 +861,7 @@ export function creerFichiers({
|
|||
await effacer(loc.absolu).catch(() => {});
|
||||
throw erreur;
|
||||
}
|
||||
tenus.set(loc.absolu, seance);
|
||||
return { pris: true };
|
||||
}
|
||||
} catch (erreur) {
|
||||
|
|
@ -875,15 +880,28 @@ export function creerFichiers({
|
|||
try {
|
||||
texte = (await fs.readFile(loc.absolu)).toString('utf8');
|
||||
} catch (erreur) {
|
||||
if (ABSENCE_EN_LECTURE.has(erreur?.code)) return;
|
||||
if (ABSENCE_EN_LECTURE.has(erreur?.code)) {
|
||||
tenus.delete(loc.absolu);
|
||||
return;
|
||||
}
|
||||
throw erreur;
|
||||
}
|
||||
if (analyserVerrou(texte)?.seance === seance) await effacer(loc.absolu);
|
||||
if (analyserVerrou(texte)?.seance === seance) {
|
||||
await effacer(loc.absolu);
|
||||
tenus.delete(loc.absolu);
|
||||
}
|
||||
} catch (erreur) {
|
||||
throw echecEcriture(loc, erreur);
|
||||
}
|
||||
},
|
||||
|
||||
// Hors des primitives, que servir ne publie pas : les verrous pris et
|
||||
// pas rendus, [{ chemin, seance }], chemin absolu, dans l'ordre de leur
|
||||
// prise. Le processus principal les efface à sa sortie.
|
||||
verrousTenus() {
|
||||
return [...tenus].map(([chemin, seance]) => ({ chemin, seance }));
|
||||
},
|
||||
|
||||
// shell.openPath rend '' quand l'explorateur s'ouvre, et sinon la raison
|
||||
// de l'échec.
|
||||
async ouvrirDansExplorateur(racine) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
// Processus principal de la coquille Electron (§ 13.1) : une fenêtre qui
|
||||
// charge la page construite par Vite dans www/, et le système de fichiers de
|
||||
// la plateforme (electron/fichiers.js), offert à la page par un canal IPC
|
||||
// par primitive, que electron/preload.cjs expose sous window.gtt.fichiers.
|
||||
// par primitive, que electron/preload.cjs expose sous window.gtt.fichiers ;
|
||||
// un canal de plus, gtt:copier, remet un texte au presse-papiers du système
|
||||
// (window.gtt.copier).
|
||||
//
|
||||
// La page n'a ni Node ni accès au disque : isolation de contexte active,
|
||||
// intégration de Node coupée, rendu en bac à sable. Elle n'atteint le
|
||||
|
|
@ -12,8 +14,8 @@
|
|||
// une racine que ce processus connaît et un chemin relatif à elle. Un nom
|
||||
// importé interprété comme du balisage reste ainsi une faute d'affichage, et
|
||||
// non un accès au disque.
|
||||
import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from 'electron';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { readdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -75,6 +77,28 @@ const fichiers = creerFichiers({
|
|||
});
|
||||
servir(fichiers, (canal, gestionnaire) => ipcMain.handle(canal, gestionnaire));
|
||||
|
||||
// Le presse-papiers : le texte reçu, une chaîne, remplace son contenu.
|
||||
ipcMain.handle('gtt:copier', (_evenement, texte) => {
|
||||
if (typeof texte !== 'string') throw new TypeError("le texte à copier n'est pas une chaîne");
|
||||
clipboard.writeText(texte);
|
||||
});
|
||||
|
||||
// Les verrous que la page a pris et pas rendus s'effacent à la sortie (§ 8.8) :
|
||||
// la page ne survit pas au processus principal, et une fermeture de la fenêtre
|
||||
// ne lui laisse pas le temps de les rendre elle-même. Chacun se relit, de
|
||||
// façon synchrone comme le retrait du profil — Electron n'attend aucune
|
||||
// promesse d'un écouteur de sortie —, et ne s'efface que s'il porte encore la
|
||||
// séance qui l'a pris : un verrou repris par une autre séance reste.
|
||||
app.on('quit', () => {
|
||||
for (const { chemin, seance } of fichiers.verrousTenus()) {
|
||||
try {
|
||||
if (JSON.parse(readFileSync(chemin, 'utf8'))?.seance === seance) rmSync(chemin, { force: true });
|
||||
} catch {
|
||||
// Absent, illisible ou inaccessible : rien à rendre.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function creerFenetre() {
|
||||
fenetre = new BrowserWindow({
|
||||
width: 1366,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Préchargement de la coquille (§ 13.1) : le seul pont entre la page et le
|
||||
// processus principal. Il expose window.gtt.fichiers et rien d'autre : une
|
||||
// fonction par primitive du système de fichiers, qui invoque le canal de
|
||||
// cette primitive, gtt:fichiers:<primitive>, et rend la réponse du processus
|
||||
// principal (electron/fichiers.js). La page ne reçoit jamais ipcRenderer
|
||||
// lui-même, ni une fonction qui invoquerait un canal de son choix.
|
||||
// processus principal. Il expose window.gtt et rien d'autre : sous
|
||||
// gtt.fichiers, une fonction par primitive du système de fichiers, qui
|
||||
// invoque le canal de cette primitive, gtt:fichiers:<primitive>, et rend la
|
||||
// réponse du processus principal (electron/fichiers.js) ; gtt.copier(texte),
|
||||
// qui invoque le canal gtt:copier et remet le texte au presse-papiers du
|
||||
// système. La page ne reçoit jamais ipcRenderer lui-même, ni une fonction
|
||||
// qui invoquerait un canal de son choix.
|
||||
//
|
||||
// Un préchargement exécuté en bac à sable est un script CommonJS, d'où
|
||||
// l'extension .cjs dans un paquet de type module. Son require ne connaît
|
||||
|
|
@ -42,4 +44,6 @@ for (const primitive of PRIMITIVES) {
|
|||
fichiers[primitive] = (...parametres) => ipcRenderer.invoke(canal, ...parametres);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('gtt', { fichiers });
|
||||
const copier = (texte) => ipcRenderer.invoke('gtt:copier', texte);
|
||||
|
||||
contextBridge.exposeInMainWorld('gtt', { fichiers, copier });
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- Le titre est posé par l'application, depuis ses libellés (§ 14.6). -->
|
||||
<title></title>
|
||||
<!-- Le collecteur d'erreurs (§ 19.2), script classique copié tel quel
|
||||
depuis public/ : il s'exécute avant le module d'entrée et en
|
||||
recueille aussi les erreurs d'évaluation. -->
|
||||
<script src="./collecteur.js"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
|
|
|
|||
47
public/collecteur.js
Normal file
47
public/collecteur.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le collecteur d'erreurs de la page (§ 19.2) : un script classique, que
|
||||
// index.html charge avant le module d'entrée, et qui accumule dans
|
||||
// globalThis.__gttErreurs ce qu'aucun pilote ne lit de façon portable dans la
|
||||
// console — une erreur non rattrapée, une promesse rejetée sans
|
||||
// gestionnaire, chaque appel de console.error —, dans l'ordre de leur
|
||||
// arrivée. Le pilote relit le tableau à chaque étape et échoue sur sa
|
||||
// première entrée.
|
||||
//
|
||||
// Une entrée est { type, message } : type vaut 'error', 'unhandledrejection'
|
||||
// ou 'console.error' ; message est le texte de l'erreur, la raison du rejet,
|
||||
// ou les arguments de console.error joints par une espace. Le script ne
|
||||
// s'installe qu'une fois par page : chargé de nouveau, il garde le tableau
|
||||
// et l'enveloppe déjà posés.
|
||||
(function installer() {
|
||||
'use strict';
|
||||
if (Array.isArray(globalThis.__gttErreurs)) return;
|
||||
var erreurs = [];
|
||||
globalThis.__gttErreurs = erreurs;
|
||||
|
||||
// Le texte d'une valeur levée, rejetée ou passée à console.error : le
|
||||
// message d'une erreur, la chaîne elle-même, sinon sa conversion.
|
||||
function texte(valeur) {
|
||||
if (valeur instanceof Error) return valeur.message;
|
||||
try {
|
||||
return String(valeur);
|
||||
} catch (_) {
|
||||
return Object.prototype.toString.call(valeur);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.addEventListener('error', function (evenement) {
|
||||
erreurs.push({ type: 'error', message: evenement.error !== undefined && evenement.error !== null ? texte(evenement.error) : String(evenement.message) });
|
||||
});
|
||||
globalThis.addEventListener('unhandledrejection', function (evenement) {
|
||||
erreurs.push({ type: 'unhandledrejection', message: texte(evenement.reason) });
|
||||
});
|
||||
var original = console.error;
|
||||
console.error = function () {
|
||||
var morceaux = [];
|
||||
for (var i = 0; i < arguments.length; i += 1) morceaux.push(texte(arguments[i]));
|
||||
erreurs.push({ type: 'console.error', message: morceaux.join(' ') });
|
||||
return original.apply(console, arguments);
|
||||
};
|
||||
})();
|
||||
|
|
@ -2,13 +2,25 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Essai de démarrage : construit la page, lance la vraie coquille Electron
|
||||
// par Playwright, attend sa fenêtre, lit le titre et le bandeau, puis ferme.
|
||||
// Il prouve ce que la coquille factice des épreuves (test/coquille.test.js)
|
||||
// ne peut pas dire : qu'Electron démarre sur ce système, avec ses
|
||||
// bibliothèques, et y charge la page.
|
||||
// par Playwright, attend sa fenêtre, lit le titre, le bandeau et le premier
|
||||
// écran, puis ferme. Il prouve ce que la coquille factice des épreuves
|
||||
// (test/coquille.test.js) ne peut pas dire : qu'Electron démarre sur ce
|
||||
// système, avec ses bibliothèques, y charge la page, et que l'application y
|
||||
// monte jusqu'à la liste des événements.
|
||||
//
|
||||
// node scripts/essai_demarrage.js
|
||||
//
|
||||
// Le titre de la fenêtre doit valoir le nom suivi de la version affichée, et
|
||||
// le bandeau — qui porte aussi provenance, thème, mode et « À propos » —
|
||||
// contenir la version affichée.
|
||||
//
|
||||
// La coquille se lance comme publiée par le lanceur portable dans un dossier
|
||||
// neuf, créé sous node_modules/.cache/ et retiré en sortant : la sonde de la
|
||||
// détermination y écrit son dossier de travail. Sans lui, elle créerait le
|
||||
// dossier du produit dans les vrais Documents de la machine ; et un dossier
|
||||
// sous le dossier temporaire du système relève des données applicatives
|
||||
// (§ 8.6, point 2), qui renvoient aux mêmes Documents.
|
||||
//
|
||||
// Sous Linux sans session graphique, Electron se lance sur la plateforme
|
||||
// Ozone « headless » de Chromium : la fenêtre existe et la page s'y charge
|
||||
// et s'y met en page, sans serveur d'affichage. Sous root, ou avec
|
||||
|
|
@ -16,6 +28,8 @@
|
|||
// d'utilisateur — un conteneur —, Electron se lance sans son bac à sable.
|
||||
// Sort à 0 sur un démarrage conforme, à 1 sinon.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { _electron } from 'playwright';
|
||||
import { VERSION } from '../src/version.genere.js';
|
||||
|
|
@ -35,28 +49,43 @@ execFileSync('npm', ['run', '--silent', 'build'], {
|
|||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
const cache = join(RACINE, 'node_modules', '.cache');
|
||||
mkdirSync(cache, { recursive: true });
|
||||
const publie = mkdtempSync(join(cache, 'essai-demarrage-'));
|
||||
|
||||
const attendu = titreAvecVersion(VERSION.affichee);
|
||||
const application = await _electron.launch({
|
||||
args: [
|
||||
...(sansBacASable ? ['--no-sandbox'] : []),
|
||||
...(sansEcran ? ['--ozone-platform=headless'] : []),
|
||||
'.',
|
||||
],
|
||||
cwd: RACINE,
|
||||
timeout: ATTENTE_MS,
|
||||
});
|
||||
let conforme = false;
|
||||
try {
|
||||
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
|
||||
await fenetre.locator('header.bandeau .titre').waitFor({ timeout: ATTENTE_MS });
|
||||
const titre = await fenetre.title();
|
||||
const bandeau = (await fenetre.locator('header.bandeau .titre').textContent()).trim();
|
||||
conforme = titre === attendu && bandeau === attendu;
|
||||
console.log(`Titre de la fenêtre : ${titre}`);
|
||||
console.log(`Bandeau : ${bandeau}`);
|
||||
if (!conforme) console.error(`Attendu : ${attendu}`);
|
||||
const application = await _electron.launch({
|
||||
args: [
|
||||
...(sansBacASable ? ['--no-sandbox'] : []),
|
||||
...(sansEcran ? ['--ozone-platform=headless'] : []),
|
||||
'.',
|
||||
],
|
||||
cwd: RACINE,
|
||||
env: { ...process.env, PORTABLE_EXECUTABLE_DIR: publie },
|
||||
timeout: ATTENTE_MS,
|
||||
});
|
||||
try {
|
||||
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
|
||||
// La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté :
|
||||
// le bandeau s'y voit par sa largeur minimale, la liste n'y est que
|
||||
// présente.
|
||||
await fenetre.locator('[data-bandeau]').waitFor({ timeout: ATTENTE_MS });
|
||||
await fenetre.locator('[data-ecran="accueil"]').waitFor({ state: 'attached', timeout: ATTENTE_MS });
|
||||
const titre = await fenetre.title();
|
||||
const bandeau = (await fenetre.locator('[data-bandeau]').textContent()).replace(/\s+/g, ' ').trim();
|
||||
const erreurs = await fenetre.evaluate(() => globalThis.__gttErreurs ?? null);
|
||||
conforme = titre === attendu && bandeau.includes(VERSION.affichee) && Array.isArray(erreurs) && erreurs.length === 0;
|
||||
console.log(`Titre de la fenêtre : ${titre}`);
|
||||
console.log(`Bandeau : ${bandeau}`);
|
||||
console.log(`Erreurs recueillies : ${JSON.stringify(erreurs)}`);
|
||||
if (!conforme) console.error(`Attendu : titre « ${attendu} », bandeau portant ${VERSION.affichee}, collecteur posé et vide`);
|
||||
} finally {
|
||||
await application.close();
|
||||
}
|
||||
} finally {
|
||||
await application.close();
|
||||
rmSync(publie, { recursive: true, force: true });
|
||||
}
|
||||
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
|
||||
process.exit(conforme ? 0 : 1);
|
||||
|
|
|
|||
|
|
@ -170,11 +170,13 @@ const EN_TETE_LICENCE = [
|
|||
/**
|
||||
* Contenu exact de src/version.genere.js pour une version affichée :
|
||||
* l'en-tête de licence, une ligne vide, puis la constante VERSION sous ses
|
||||
* formes affichée et technique. Le module ne porte que la version ; la
|
||||
* provenance de la construction vient du mode de Vite.
|
||||
* formes affichée, technique et Windows — celle que le panneau « À propos »
|
||||
* met en regard de l'affichée (§ 18.6), sans la recalculer. Le module ne
|
||||
* porte que la version ; la provenance de la construction vient du mode de
|
||||
* Vite.
|
||||
*/
|
||||
export function engendrerModule(versionAffichee) {
|
||||
const { affichee, technique } = deriver(versionAffichee);
|
||||
const { affichee, technique, windows } = deriver(versionAffichee);
|
||||
return [
|
||||
...EN_TETE_LICENCE,
|
||||
'',
|
||||
|
|
@ -182,6 +184,7 @@ export function engendrerModule(versionAffichee) {
|
|||
'export const VERSION = Object.freeze({',
|
||||
` affichee: '${affichee}',`,
|
||||
` technique: '${technique}',`,
|
||||
` windows: '${windows}',`,
|
||||
'});',
|
||||
'',
|
||||
].join('\n');
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { describe, test } from '../test/lanceur.js';
|
||||
import {
|
||||
controler,
|
||||
|
|
@ -242,6 +242,7 @@ const MODULE = [
|
|||
'export const VERSION = Object.freeze({',
|
||||
" affichee: '2026.10.05.01',",
|
||||
" technique: '2026.1005.1',",
|
||||
" windows: '2026.1005.1.0',",
|
||||
'});',
|
||||
'',
|
||||
].join('\n');
|
||||
|
|
@ -772,6 +773,20 @@ describe('version : engendrement', () => {
|
|||
assert.equal(engendrerModule(VERSION), MODULE);
|
||||
});
|
||||
|
||||
test('le module engendré porte les formes affichée, technique et Windows de deriver, et elles seules', async () => {
|
||||
const dossier = mkdtempSync(join(tmpdir(), 'version-module-'));
|
||||
try {
|
||||
const chemin = join(dossier, 'version.genere.js');
|
||||
writeFileSync(chemin, engendrerModule('2026.10.06.02'));
|
||||
const { VERSION: engendree } = await import(pathToFileURL(chemin).href);
|
||||
const { affichee, technique, windows } = deriver('2026.10.06.02');
|
||||
assert.deepEqual(engendree, { affichee, technique, windows });
|
||||
assert.ok(Object.isFrozen(engendree));
|
||||
} finally {
|
||||
rmSync(dossier, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('engendrer réécrit le module, package.json et les deux champs du verrou', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');
|
||||
|
|
|
|||
144
src/application/bilans.js
Normal file
144
src/application/bilans.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5) : ce qu'un changement
|
||||
// de défaut, de tours ou la suppression d'une table emporterait, compté sur
|
||||
// la charge courante sans rien écrire, pour l'énoncer avant d'appliquer. La
|
||||
// commande qui applique lit le même bilan : changerDefaut lève le premier de
|
||||
// ses refus. Les refus et avertissements d'une capacité sont ceux de
|
||||
// controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne
|
||||
// modifie ce qu'elle reçoit.
|
||||
import { ordreDesReservations } from '../csv/import.js';
|
||||
import { capacite } from '../stockage/document.js';
|
||||
import { controlerCapacites } from './capacite.js';
|
||||
import { presents } from './places.js';
|
||||
|
||||
const croissant = (a, b) => a - b;
|
||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const parNumero = (a, b) => a.numero - b.numero || a.id - b.id;
|
||||
|
||||
// Un nombre entier attendu ; sa lecture depuis une saisie appartient à
|
||||
// lireEntier.
|
||||
function exigerEntier(valeur, nom) {
|
||||
if (!Number.isSafeInteger(valeur)) throw new TypeError(`${nom} : entier attendu, reçu ${JSON.stringify(valeur)}`);
|
||||
}
|
||||
|
||||
// L'enregistrement de la table d'identifiant id, ou TypeError.
|
||||
function tableDe(charge, id) {
|
||||
const table = charge.tables.find((candidate) => candidate.id === id);
|
||||
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
|
||||
return table;
|
||||
}
|
||||
|
||||
// Les identifiants croissants des placements de la liste qui satisfont
|
||||
// touche ; un placement fautif, de forme quelconque, se lit sans lever.
|
||||
const identifiants = (placements, touche) =>
|
||||
placements.filter((placement) => touche(placement)).map(({ id }) => id).sort(croissant);
|
||||
|
||||
/**
|
||||
* Le changement du défaut à sieges places (§ 6.1), énoncé avant d'être
|
||||
* appliqué : suivent, le nombre de tables qui suivent le défaut ; avant et
|
||||
* apres, le défaut d'aujourd'hui et sieges ; surchargees, les capacités des
|
||||
* tables surchargées par numéro de table ; totalAvant et totalApres, la
|
||||
* somme des capacités avant et après ; participants, les personnes non
|
||||
* exclues. realigner : les surchargées passent aussi à sieges.
|
||||
*
|
||||
* Chaque table dont la capacité change se contrôle comme une saisie au champ
|
||||
* (§ 6.2) : refus, le premier de chaque table qui refuse, {table, code,
|
||||
* details}, par ordre de charge.tables ; avertissements, de même, ceux des
|
||||
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places refusent pour
|
||||
* l'événement entier : un seul refus, MOINS_DE_DEUX, table null.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {number} sieges
|
||||
* @param {{realigner?: boolean}} [options]
|
||||
* @returns {{suivent: number, avant: number, apres: number, surchargees: number[], totalAvant: number,
|
||||
* totalApres: number, participants: number, refus: Array<{table: number|null, code: string, details: Object}>,
|
||||
* avertissements: Array<{table: number, code: string, details: Object}>}}
|
||||
* @throws {TypeError} sieges qui n'est pas un entier
|
||||
*/
|
||||
export function bilanChangementDefaut(charge, sieges, { realigner = false } = {}) {
|
||||
exigerEntier(sieges, 'sieges');
|
||||
const apresDe = (table) => (table.sieges === null || realigner ? sieges : table.sieges);
|
||||
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
|
||||
const changements = new Map(
|
||||
charge.tables.filter((table) => apresDe(table) !== capacite(charge, table)).map((table) => [table.id, apresDe(table)]),
|
||||
);
|
||||
const controle =
|
||||
sieges < 2
|
||||
? { refus: [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }], avertissements: [] }
|
||||
: controlerCapacites(charge, changements, { origine: 'champ' });
|
||||
return {
|
||||
suivent: charge.tables.filter((table) => table.sieges === null).length,
|
||||
avant: charge.evenement.siegesParDefaut,
|
||||
apres: sieges,
|
||||
surchargees: charge.tables
|
||||
.filter((table) => table.sieges !== null)
|
||||
.sort(parNumero)
|
||||
.map((table) => table.sieges),
|
||||
totalAvant: somme(charge.tables.map((table) => capacite(charge, table))),
|
||||
totalApres: somme(charge.tables.map(apresDe)),
|
||||
participants: presents(charge).size,
|
||||
refus: controle.refus,
|
||||
avertissements: controle.avertissements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le changement du nombre de tours à tours (§ 2.1 étape 5, § 4.3) :
|
||||
* reservationsDetruites, les réservations « tour désigné » au-delà, dans
|
||||
* l'ordre canonique ; propositionsTouchees, les identifiants croissants des
|
||||
* propositions qui ne porteront pas tours tours, et retenuTouche de même
|
||||
* pour le retenu — ils restent, en dérive TOURS_CHANGES (§ 9). Le nombre de
|
||||
* tours d'aujourd'hui ne touche rien. La portée « tous » ne se détruit
|
||||
* jamais : elle n'énumère aucun tour.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {number} tours
|
||||
* @returns {{reservationsDetruites: Array<{participant: number, table: number, tour: number}>,
|
||||
* propositionsTouchees: number[], retenuTouche: boolean}}
|
||||
* @throws {TypeError} tours qui n'est pas un entier
|
||||
*/
|
||||
export function bilanTours(charge, tours) {
|
||||
exigerEntier(tours, 'tours');
|
||||
const change = tours !== charge.evenement.tours;
|
||||
const touche = (placement) => change && placement?.tours !== tours;
|
||||
return {
|
||||
reservationsDetruites: charge.reservations
|
||||
.filter(({ portee, tour }) => portee === 'tour' && tour > tours)
|
||||
.sort(ordreDesReservations)
|
||||
.map(({ participant, table, tour }) => ({ participant, table, tour })),
|
||||
propositionsTouchees: identifiants(charge.propositions, touche),
|
||||
retenuTouche: charge.retenu !== null && touche(charge.retenu),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* La suppression de la table id (§ 6.2, § 4) : ses réservations, suspendues
|
||||
* comprises, dans l'ordre canonique, et ses titres par siège puis libellé,
|
||||
* qui partent avec elle ; propositionsTouchees, les identifiants croissants
|
||||
* des propositions qui la déclarent, et retenuTouche de même — ils restent,
|
||||
* en dérive TABLE_SUPPRIMEE (§ 9). Une table nue rend deux listes vides.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {number} id
|
||||
* @returns {{reservations: Array<{participant: number, portee: 'tous'|'tour', tour: number|null, siege: number|null}>,
|
||||
* titres: Array<{siege: number, libelle: string}>, propositionsTouchees: number[], retenuTouche: boolean}}
|
||||
* @throws {TypeError} une table inconnue
|
||||
*/
|
||||
export function bilanSuppressionTable(charge, id) {
|
||||
tableDe(charge, id);
|
||||
const declare = (placement) => Array.isArray(placement?.tables) && placement.tables.includes(id);
|
||||
return {
|
||||
reservations: charge.reservations
|
||||
.filter(({ table }) => table === id)
|
||||
.sort(ordreDesReservations)
|
||||
.map(({ participant, portee, tour, siege }) => ({ participant, portee, tour, siege })),
|
||||
titres: charge.titres
|
||||
.filter(({ table }) => table === id)
|
||||
.sort((a, b) => a.siege - b.siege || comparerTextes(a.libelle, b.libelle))
|
||||
.map(({ siege, libelle }) => ({ siege, libelle })),
|
||||
propositionsTouchees: identifiants(charge.propositions, declare),
|
||||
retenuTouche: charge.retenu !== null && declare(charge.retenu),
|
||||
};
|
||||
}
|
||||
232
src/application/bilans.test.js
Normal file
232
src/application/bilans.test.js
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5, § 4.3) : ce
|
||||
// qu'un changement de défaut, de tours ou une suppression de table emporte,
|
||||
// énoncé avant d'appliquer, sans rien écrire. Les noms d'épreuve sont
|
||||
// inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { nombrePlaces, normaliser } from '../moteur/configuration.js';
|
||||
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from './bilans.js';
|
||||
import { COMMANDES } from './commandes.js';
|
||||
|
||||
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Bruyère', 'Sarcelle', 'Ancolie', 'Mélèze', 'Quenouille', 'Pivoine'];
|
||||
|
||||
const personne = (id, autres = {}) => ({
|
||||
id,
|
||||
nom: NOMS[(id - 1) % NOMS.length],
|
||||
prenom: id === 2 ? 'Théo' : null,
|
||||
appartenance: null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...autres,
|
||||
});
|
||||
|
||||
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
|
||||
|
||||
// Une charge : les tables données ({id, sieges}, numéro égal à l'identifiant),
|
||||
// n personnes dont les exclus, R tours, un défaut.
|
||||
function chargeDe({ tables, n, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] }) {
|
||||
const charge = creerCharge({ id: 'evt-bilans', nom: 'Soirée des Merles', siegesParDefaut: defaut, tours });
|
||||
charge.reglages.attribuerSieges = attribuerSieges;
|
||||
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: exclus.includes(rang + 1) }));
|
||||
charge.tables = tables.map(({ id, sieges = null }) => ({ id, numero: id, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.reservations = reservations;
|
||||
charge.titres = titres;
|
||||
charge.prochainsIds = { participant: n + 1, table: Math.max(0, ...tables.map(({ id }) => id)) + 1, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Ajoute la proposition id, dont les listes de chaque tour suivent l'ordre
|
||||
// de charge.tables, et la retient quand retenu est vrai.
|
||||
function placer(charge, listes, { id, retenu = false }) {
|
||||
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
|
||||
const plan = {
|
||||
siegesAttribues: charge.reglages.attribuerSieges,
|
||||
tables: charge.tables.map(({ id: t }) => t),
|
||||
capacites: charge.tables.map(({ sieges }) => sieges ?? charge.evenement.siegesParDefaut),
|
||||
tours: listes.length,
|
||||
participants,
|
||||
placement: listes.map((tour) => ({ sieges: tour, reserve: participants.filter((p) => !tour.flat().includes(p)) })),
|
||||
};
|
||||
charge.propositions.push({ id, graine: id, arret: 40, historique: 1000, produitVersion: 'essai', ...plan });
|
||||
charge.prochainsIds.proposition = id + 1;
|
||||
if (retenu) {
|
||||
charge.retenu = { proposition: id, ...structuredClone(plan) };
|
||||
charge.evenement.etat = 'retenu';
|
||||
} else if (charge.evenement.etat === 'brouillon') {
|
||||
charge.evenement.etat = 'propose';
|
||||
}
|
||||
return charge;
|
||||
}
|
||||
|
||||
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
|
||||
|
||||
// Treize tables : neuf suivent 8, les tables 3, 6, 9 et 12 sont surchargées à
|
||||
// 7, 7, 6 et 12 ; 120 personnes, dont 2 exclues.
|
||||
function treizeTables(options = {}) {
|
||||
const surcharges = new Map([[3, 7], [6, 7], [9, 6], [12, 12]]);
|
||||
const tables = Array.from({ length: 13 }, (_, rang) => ({ id: rang + 1, sieges: surcharges.get(rang + 1) ?? null }));
|
||||
return chargeDe({ tables, n: 120, exclus: [40, 80], ...options });
|
||||
}
|
||||
|
||||
describe('bilans : changer le défaut (§ 6.1)', () => {
|
||||
test("l'énoncé chiffré du § 6.1 : 9 tables passent de 8 à 10, 4 surchargées ne changent pas, 104 → 122 pour 118", () => {
|
||||
const charge = treizeTables();
|
||||
const bilan = bilanChangementDefaut(charge, 10);
|
||||
assert.deepEqual(bilan, {
|
||||
suivent: 9,
|
||||
avant: 8,
|
||||
apres: 10,
|
||||
surchargees: [7, 7, 6, 12],
|
||||
totalAvant: 104,
|
||||
totalApres: 122,
|
||||
participants: 118,
|
||||
refus: [],
|
||||
avertissements: [],
|
||||
});
|
||||
// Le total et les participants sont ceux de l'instance de la charge appliquée.
|
||||
const apres = COMMANDES.get('changerDefaut')(charge, { sieges: 10, realigner: false }).charge;
|
||||
const instance = normaliser(configurationDepuisCharge(apres));
|
||||
assert.equal(bilan.totalApres, nombrePlaces(instance));
|
||||
assert.equal(bilan.participants, instance.N);
|
||||
});
|
||||
|
||||
test('réaligner : les surchargées changent aussi, et leurs refus en sont', () => {
|
||||
const charge = treizeTables({ attribuerSieges: true, titres: [{ table: 12, siege: 12, libelle: 'animateur' }] });
|
||||
const realigne = bilanChangementDefaut(charge, 10, { realigner: true });
|
||||
assert.equal(realigne.totalApres, 130);
|
||||
assert.deepEqual(realigne.refus, [
|
||||
{
|
||||
table: 12,
|
||||
code: 'SIEGES_RETIRES',
|
||||
details: {
|
||||
table: 12,
|
||||
sieges: [{ siege: 12, cause: 'SIEGE_TITRE', libelle: 'animateur' }],
|
||||
remede: { commande: 'retirerTitre', arguments: { table: 12, siege: 12 } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(bilanChangementDefaut(charge, 10).refus, []);
|
||||
});
|
||||
|
||||
test('une baisse se contrôle table par table : la table 2, sous ses 7 réservations, refuse ; les autres non', () => {
|
||||
const reservations = [1, 2, 3, 4, 5, 6, 7].map((id) => resa(id, 2, null));
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 20, reservations });
|
||||
const bilan = bilanChangementDefaut(charge, 6);
|
||||
assert.deepEqual(bilan.refus, [
|
||||
{ table: 2, code: 'SOUS_RESERVATIONS', details: { table: 2, n: 7, tour: 1, remede: afficherTour(1) } },
|
||||
]);
|
||||
assert.equal(bilan.totalApres, 18);
|
||||
assert.deepEqual(bilanChangementDefaut(charge, 1).refus, [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }]);
|
||||
});
|
||||
|
||||
test('une baisse sous un retenu occupé avertit SURNUMERAIRES : deux personnes de la table 3 au tour 2', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
|
||||
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);
|
||||
placer(
|
||||
charge,
|
||||
[
|
||||
[suite(1, 6), suite(7, 12), suite(13, 18)],
|
||||
[suite(1, 5), suite(6, 10), suite(11, 18)],
|
||||
],
|
||||
{ id: 1, retenu: true },
|
||||
);
|
||||
const bilan = bilanChangementDefaut(charge, 6);
|
||||
assert.deepEqual(bilan.refus, []);
|
||||
assert.deepEqual(bilan.avertissements, [
|
||||
{ table: 3, code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [17, 18] } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("le bilan n'écrit rien : la charge reçue, gelée, ne change pas", () => {
|
||||
const charge = Object.freeze(treizeTables());
|
||||
assert.equal(bilanChangementDefaut(charge, 6).totalApres, 9 * 6 + 32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bilans : changer les tours (§ 2.1 étape 5, § 4.3)', () => {
|
||||
// R = 4 : deux réservations au tour 4, une au tour 2, une « tous » ; deux
|
||||
// propositions et un retenu.
|
||||
function quatreTours() {
|
||||
const reservations = [resa(1, 1, null, 4), resa(2, 2, null, 4), resa(3, 1, null, 2), resa(4, 2, null)];
|
||||
const charge = chargeDe({ tables: [{ id: 1, sieges: 4 }, { id: 2, sieges: 4 }], n: 8, tours: 4, reservations });
|
||||
const tour = [[1, 2, 3, 5], [4, 6, 7, 8]];
|
||||
const listes = [tour, [[1, 3, 5, 6], [2, 4, 7, 8]], [[1, 5, 6, 7], [2, 3, 4, 8]], [[1, 3, 5, 7], [2, 4, 6, 8]]];
|
||||
placer(charge, listes, { id: 1 });
|
||||
placer(charge, listes, { id: 2, retenu: true });
|
||||
return charge;
|
||||
}
|
||||
|
||||
test('retirer le tour 4 détruit ses deux réservations « tour » ; propositions et retenu touchés', () => {
|
||||
const charge = quatreTours();
|
||||
assert.deepEqual(bilanTours(charge, 3), {
|
||||
reservationsDetruites: [
|
||||
{ participant: 1, table: 1, tour: 4 },
|
||||
{ participant: 2, table: 2, tour: 4 },
|
||||
],
|
||||
propositionsTouchees: [1, 2],
|
||||
retenuTouche: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("le même nombre ne touche rien ; en ajouter ne détruit rien, mais périme les placements", () => {
|
||||
const charge = quatreTours();
|
||||
assert.deepEqual(bilanTours(charge, 4), { reservationsDetruites: [], propositionsTouchees: [], retenuTouche: false });
|
||||
assert.deepEqual(bilanTours(charge, 5), { reservationsDetruites: [], propositionsTouchees: [1, 2], retenuTouche: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bilans : supprimer une table (§ 6.2, § 4)", () => {
|
||||
function garnie() {
|
||||
const charge = chargeDe({
|
||||
tables: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
n: 6,
|
||||
reservations: [resa(2, 2, 2), resa(5, 1, null)],
|
||||
titres: [{ table: 2, siege: 2, libelle: 'animateur' }, { table: 1, siege: 1, libelle: 'accueil' }],
|
||||
});
|
||||
const listes = [[[1, 5], [2, 3], [4, 6]], [[3, 5], [2, 6], [1, 4]]];
|
||||
placer(charge, listes, { id: 1 });
|
||||
placer(charge, listes, { id: 2, retenu: true });
|
||||
return charge;
|
||||
}
|
||||
|
||||
test('la table 2 porte la réservation « tous » de Théo Pervenche et un titre : le bilan les nomme, et les placements touchés', () => {
|
||||
assert.deepEqual(bilanSuppressionTable(garnie(), 2), {
|
||||
reservations: [{ participant: 2, portee: 'tous', tour: null, siege: 2 }],
|
||||
titres: [{ siege: 2, libelle: 'animateur' }],
|
||||
propositionsTouchees: [1, 2],
|
||||
retenuTouche: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('une table nue : deux listes vides ; une table créée après les placements ne les touche pas', () => {
|
||||
const charge = garnie();
|
||||
const nue = bilanSuppressionTable(charge, 3);
|
||||
assert.deepEqual([nue.reservations, nue.titres], [[], []]);
|
||||
const creee = COMMANDES.get('creerTable')(charge, { forme: 'carree' }).charge;
|
||||
const idCree = creee.tables.at(-1).id;
|
||||
// creerTable fait entrer la table au retenu ; aucune proposition ne la déclare.
|
||||
assert.deepEqual(bilanSuppressionTable(creee, idCree), { reservations: [], titres: [], propositionsTouchees: [], retenuTouche: true });
|
||||
});
|
||||
|
||||
test('les titres se rangent par siège puis libellé', () => {
|
||||
const charge = garnie();
|
||||
charge.titres.push({ table: 2, siege: 1, libelle: 'hôte' }, { table: 2, siege: 2, libelle: 'accueil' });
|
||||
assert.deepEqual(bilanSuppressionTable(charge, 2).titres, [
|
||||
{ siege: 1, libelle: 'hôte' },
|
||||
{ siege: 2, libelle: 'accueil' },
|
||||
{ siege: 2, libelle: 'animateur' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('une table inconnue est une faute du code ; des places ou des tours non entiers aussi', () => {
|
||||
assert.throws(() => bilanChangementDefaut(garnie(), '6'), TypeError);
|
||||
assert.throws(() => bilanTours(garnie(), 2.5), TypeError);
|
||||
assert.throws(() => bilanSuppressionTable(garnie(), 9), TypeError);
|
||||
});
|
||||
});
|
||||
402
src/application/capacite.js
Normal file
402
src/application/capacite.js
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le contrôle de capacité (§ 6.2, § 7.1) : ce qu'une capacité de table
|
||||
// refuse et ce dont elle avertit, et le seul — les commandes du mobilier et
|
||||
// leurs bilans l'appellent, aucun composant ne le refait (§ 13.2). Aussi la
|
||||
// seule lecture d'un entier saisi (lireEntier), et la réécriture du retenu
|
||||
// qu'une capacité changée entraîne (retenuAuxCapacites).
|
||||
//
|
||||
// Un refus a la forme { code, details }, le remède dans details.remede. Ils
|
||||
// se contrôlent dans cet ordre, et le premier seul est rendu :
|
||||
// MOINS_DE_DEUX ; SOUS_RESERVATIONS, sous le plus grand compte de
|
||||
// reservationsParTour ; SIEGES_RETIRES, un siège au-delà de la capacité qui
|
||||
// porte un titre ou une réservation, suspendue comprise — l'analyse du
|
||||
// fichier refuse un tel siège (§ 6.2) — ; SOUS_OCCUPES, pour la poignée, sous
|
||||
// l'occupation de chaque tour telle qu'etatPlaces la montre. Le champ et la
|
||||
// poignée décrivent deux gestes (§ 6.2, § 7.1) : la poignée, geste de
|
||||
// géométrie fait sans regarder les autres tours, ne désassoit personne ; le
|
||||
// champ, saisie délibérée, verse dans la réserve les occupants des chaises
|
||||
// retirées et le dit (SURNUMERAIRES).
|
||||
//
|
||||
// Le retenu suit les capacités : ce qui ne tient plus va dans la réserve de
|
||||
// chaque tour, puis la capacité s'écrit dans retenu.capacites, de sorte que
|
||||
// le retenu ne gagne aucune raison de dérive. Sièges attribués, le siège est
|
||||
// le rang : une personne que la table réserve à ce tour, assise sur une
|
||||
// chaise que le plan montrait et que la nouvelle capacité retire, revient
|
||||
// d'abord sur une chaise libre dans la capacité, sinon sur celle du plus
|
||||
// haut rang qu'occupe un non-réservé ; puis les rangs au-delà de la capacité
|
||||
// partent, et la liste perd ses chaises vides de fin. Aucune réservation que
|
||||
// le plan montrait tenue ne cesse ainsi de l'être, et un rang au-delà de la
|
||||
// capacité courante, que le plan ne montre pas, n'assied personne. Sans
|
||||
// attribution, la liste garde les personnes que
|
||||
// le plan montre assises à la table avant et après le geste — l'affichage
|
||||
// élit d'abord celles que la table réserve —, puis, tant qu'elle dépasse,
|
||||
// perd ses absents, le plus petit identifiant d'abord : un geste n'assied
|
||||
// personne qu'il ne nomme. Un retenu fautif ne se réécrit pas. Aucune
|
||||
// fonction ne modifie ce qu'elle reçoit.
|
||||
import { ordreDesReservations } from '../csv/import.js';
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { capacite } from '../stockage/document.js';
|
||||
import { fautes } from '../stockage/placements.js';
|
||||
import { couvre, etatPlaces, presents, repartirSieges, reservationsActives } from './places.js';
|
||||
import { reservationsParTour } from './pose.js';
|
||||
|
||||
const croissant = (a, b) => a - b;
|
||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
// Un entier écrit en chiffres décimaux seuls : ni signe, ni séparateur, ni
|
||||
// exposant.
|
||||
const CHIFFRES = /^[0-9]+$/;
|
||||
|
||||
const ORIGINES = new Set(['champ', 'poignee']);
|
||||
|
||||
// Le seuil de lisibilité, nommé dans le registre des constantes mesurées
|
||||
// (§ 14.1) : un nom de constante, que la table des libellés ne connaît pas.
|
||||
const SEUIL_LISIBLE = { nom: 'SEUIL_SIEGES_LISIBLES' };
|
||||
|
||||
const refusDe = (code, details, remede) => ({ code, details: { ...details, remede } });
|
||||
const avertissement = (code, details) => ({ code, details });
|
||||
|
||||
// Le remède d'une capacité sous un compte : montrer le tour qui l'atteint,
|
||||
// sur le plan du retenu ou des réservations.
|
||||
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
|
||||
|
||||
/**
|
||||
* Lit un entier saisi (§ 6.2) : des chiffres décimaux seuls, blancs de bord
|
||||
* admis, ou un nombre entier ≥ 0 ; tout autre texte — signe, décimale,
|
||||
* virgule, lettres, vide, ou un entier au-delà de 2^53 − 1 — refuse
|
||||
* NON_ENTIER {saisie}, remède null. Le domaine de la valeur — au moins 2
|
||||
* places, au moins un tour — appartient à son contrôle.
|
||||
*
|
||||
* @param {string|number} saisie
|
||||
* @returns {{valeur: number}|{refus: {code: 'NON_ENTIER', details: {saisie: string|number, remede: null}}}}
|
||||
* @throws {TypeError} une saisie qui n'est ni un texte ni un nombre
|
||||
*/
|
||||
export function lireEntier(saisie) {
|
||||
if (typeof saisie === 'number') {
|
||||
if (Number.isSafeInteger(saisie) && saisie >= 0) return { valeur: saisie + 0 };
|
||||
} else if (typeof saisie === 'string') {
|
||||
const texte = saisie.trim();
|
||||
const valeur = CHIFFRES.test(texte) ? Number(texte) : Number.NaN;
|
||||
if (Number.isSafeInteger(valeur)) return { valeur };
|
||||
} else {
|
||||
throw new TypeError(`lireEntier : texte ou nombre attendu, reçu ${JSON.stringify(saisie)}`);
|
||||
}
|
||||
return { refus: refusDe('NON_ENTIER', { saisie }, null) };
|
||||
}
|
||||
|
||||
// L'enregistrement de la table d'identifiant id : une table inconnue est une
|
||||
// faute de l'appelant, que l'interface n'offre jamais.
|
||||
function tableDe(charge, id) {
|
||||
const table = charge.tables.find((candidate) => candidate.id === id);
|
||||
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
|
||||
return table;
|
||||
}
|
||||
|
||||
// Le plus grand compte d'une liste par tour, et le premier tour qui
|
||||
// l'atteint.
|
||||
function maximum(comptes) {
|
||||
const n = Math.max(0, ...comptes);
|
||||
return { n, tour: comptes.indexOf(n) + 1 };
|
||||
}
|
||||
|
||||
// Les sièges au-delà de sieges qui portent une saisie, par numéro
|
||||
// décroissant ; à un même siège, ses titres par libellé, puis ses
|
||||
// réservations dans l'ordre canonique. Une réservation suspendue — sa
|
||||
// personne exclue — y est, sièges attribués ou non.
|
||||
function siegesPortes(charge, idTable, sieges) {
|
||||
const ids = presents(charge);
|
||||
const titres = charge.titres.filter(({ table, siege }) => table === idTable && siege > sieges);
|
||||
const reservations = charge.reservations.filter(({ table, siege }) => table === idTable && siege !== null && siege > sieges);
|
||||
const numeros = [...new Set([...titres, ...reservations].map(({ siege }) => siege))].sort((a, b) => b - a);
|
||||
return numeros.flatMap((numero) => [
|
||||
...titres
|
||||
.filter(({ siege }) => siege === numero)
|
||||
.sort((a, b) => comparerTextes(a.libelle, b.libelle))
|
||||
.map(({ siege, libelle }) => ({ siege, cause: 'SIEGE_TITRE', libelle })),
|
||||
...reservations
|
||||
.filter(({ siege }) => siege === numero)
|
||||
.sort(ordreDesReservations)
|
||||
.map(({ siege, participant }) => ({ siege, cause: 'SIEGE_RESERVE', participant, suspendue: !ids.has(participant) })),
|
||||
]);
|
||||
}
|
||||
|
||||
// Le remède d'un siège retiré : le retrait de son titre, ou de sa
|
||||
// réservation, désignée par ses quatre arguments.
|
||||
function remedeDuSiege(charge, idTable, retire) {
|
||||
if (retire.cause === 'SIEGE_TITRE') return { commande: 'retirerTitre', arguments: { table: idTable, siege: retire.siege } };
|
||||
const { portee, tour } = charge.reservations
|
||||
.filter((r) => r.table === idTable && r.siege === retire.siege && r.participant === retire.participant)
|
||||
.sort(ordreDesReservations)[0];
|
||||
return { commande: 'retirerReservation', arguments: { participant: retire.participant, table: idTable, portee, tour } };
|
||||
}
|
||||
|
||||
// L'occupation de la table à chaque tour, lue dans etatPlaces : les chaises
|
||||
// qui portent un occupant ou une réservation ; sièges attribués, le plus
|
||||
// haut numéro de ces chaises quand il est plus grand.
|
||||
function occupation(charge, idTable) {
|
||||
return Array.from({ length: charge.evenement.tours }, (_, rang) => {
|
||||
const etat = etatPlaces(charge, { tour: rang + 1 });
|
||||
const { chaises } = etat.tables.find(({ id }) => id === idTable);
|
||||
const portees = chaises.filter(({ occupant, reservation }) => occupant !== null || reservation !== null);
|
||||
const plusHaut = portees.length === 0 ? 0 : portees.at(-1).siege;
|
||||
return etat.siegesAttribues ? Math.max(portees.length, plusHaut) : portees.length;
|
||||
});
|
||||
}
|
||||
|
||||
// Le premier refus d'une capacité, ou null.
|
||||
function refusDeCapacite(charge, idTable, sieges, origine) {
|
||||
if (sieges < 2) return refusDe('MOINS_DE_DEUX', {}, null);
|
||||
const reservees = maximum(reservationsParTour(charge, idTable));
|
||||
if (sieges < reservees.n) {
|
||||
return refusDe('SOUS_RESERVATIONS', { table: idTable, ...reservees }, afficherTour(reservees.tour));
|
||||
}
|
||||
const retires = siegesPortes(charge, idTable, sieges);
|
||||
if (retires.length > 0) {
|
||||
const titres = charge.reglages.attribuerSieges ? {} : { titres: retires.filter(({ cause }) => cause === 'SIEGE_TITRE').length };
|
||||
return refusDe('SIEGES_RETIRES', { table: idTable, sieges: retires, ...titres }, remedeDuSiege(charge, idTable, retires[0]));
|
||||
}
|
||||
if (origine === 'poignee') {
|
||||
const occupees = maximum(occupation(charge, idTable));
|
||||
if (sieges < occupees.n) return refusDe('SOUS_OCCUPES', { table: idTable, ...occupees }, afficherTour(occupees.tour));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Les personnes que le plan montre assises à la table au tour, dans l'ordre
|
||||
// de leurs chaises : celles d'etatPlaces (etat) pour un tour de la soirée.
|
||||
// Un tour du retenu que la soirée n'a plus ne se montre pas : il se lit
|
||||
// selon la même règle, la liste de la source répartie aux places courantes
|
||||
// par repartirSieges, les réservations « tous » de la table seules
|
||||
// couvrantes.
|
||||
function assises(charge, etat, idTable, tour) {
|
||||
if (etat !== null) {
|
||||
return etat.tables
|
||||
.find(({ id }) => id === idTable)
|
||||
.chaises.map(({ occupant }) => occupant)
|
||||
.filter((occupant) => occupant !== null);
|
||||
}
|
||||
const { retenu } = charge;
|
||||
const ids = presents(charge);
|
||||
const places = capacite(charge, tableDe(charge, idTable));
|
||||
const liste = retenu.placement[tour - 1].sieges[retenu.tables.indexOf(idTable)];
|
||||
if (retenu.siegesAttribues) return liste.slice(0, places).filter((id) => id !== null && ids.has(id));
|
||||
const tous = reservationsActives(charge).filter(({ table, portee }) => table === idTable && portee === 'tous');
|
||||
const siegeDe = new Map();
|
||||
for (const { participant, siege } of tous) if (siege !== null && !siegeDe.has(participant)) siegeDe.set(participant, siege);
|
||||
const prioritaires = new Set(tous.map(({ participant }) => participant));
|
||||
return repartirSieges(liste.filter((id) => ids.has(id)), siegeDe, places, prioritaires).filter((id) => id !== null);
|
||||
}
|
||||
|
||||
// Sièges attribués, la liste de la table réduite à ses limite premiers
|
||||
// rangs, sans chaise vide de fin. reservees (Map personne → siège réservé ou
|
||||
// null) : les personnes que la table réserve à ce tour. Chacune, assise à un
|
||||
// rang que le plan montrait — de limite à montres, la capacité courante — et
|
||||
// que la réduction retire, revient, dans l'ordre des rangs, à son siège
|
||||
// réservé quand il est libre, sinon à la première chaise libre — vide, ou
|
||||
// d'un absent, qui passe alors en réserve —, sinon à la chaise du plus haut
|
||||
// rang qu'occupe un non-réservé, qui passe en réserve. Un rang à partir de
|
||||
// montres ne ramène personne : une hausse (limite égale à montres) n'en
|
||||
// ramène aucun. La capacité a passé SIEGES_RETIRES et SOUS_RESERVATIONS :
|
||||
// chaque siège réservé est dans limite, et les réservés de la table à ce
|
||||
// tour n'y sont pas plus nombreux que les chaises ; une chaise se trouve
|
||||
// toujours.
|
||||
function ramenerReservees(liste, limite, montres, reservees, ids) {
|
||||
const reste = Array.from({ length: limite }, (_, rang) => liste[rang] ?? null);
|
||||
const libre = (occupant) => occupant === null || !ids.has(occupant);
|
||||
for (const id of liste.slice(limite, montres)) {
|
||||
if (id === null || !reservees.has(id)) continue;
|
||||
const voulu = reservees.get(id);
|
||||
let rang = voulu !== null && libre(reste[voulu - 1]) ? voulu - 1 : reste.findIndex(libre);
|
||||
if (rang < 0) rang = reste.findLastIndex((occupant) => !reservees.has(occupant));
|
||||
reste[rang] = id;
|
||||
}
|
||||
while (reste.length > 0 && reste.at(-1) === null) reste.pop();
|
||||
return reste;
|
||||
}
|
||||
|
||||
// Les personnes que la table idTable réserve au tour, chacune avec son siège
|
||||
// réservé ou null : les réservations actives (actives, dans l'ordre
|
||||
// canonique) qui couvrent ce tour ; un tour que la soirée n'a plus, l'analyse
|
||||
// n'admettant aucune portée « tour » au-delà des tours, seules les « tous »
|
||||
// le couvrent. Le siège réservé d'une personne est le premier siège non nul
|
||||
// de ses réservations, comme l'affichage le lit.
|
||||
function reserveesAuTour(actives, idTable, tour) {
|
||||
const reservees = new Map();
|
||||
for (const reservation of actives) {
|
||||
const { table, participant, siege } = reservation;
|
||||
if (table !== idTable || !couvre(reservation, tour)) continue;
|
||||
if (!reservees.has(participant) || reservees.get(participant) === null) reservees.set(participant, siege);
|
||||
}
|
||||
return reservees;
|
||||
}
|
||||
|
||||
// La charge où chaque table de changements (Map identifiant → places) porte
|
||||
// ses nouvelles places, le retenu tel quel.
|
||||
const avecCapacites = (charge, changements) => ({
|
||||
...charge,
|
||||
tables: charge.tables.map((table) => (changements.has(table.id) ? { ...table, sieges: changements.get(table.id) } : table)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce que de nouvelles capacités font au retenu de la charge, par table de
|
||||
* changements qu'il déclare, dans l'ordre de charge.tables, et par tour du
|
||||
* retenu : la liste qui reste, ce qui en est versé à la réserve, et les
|
||||
* personnes que le plan du tour montrait assises à la table et qui passent
|
||||
* dans la réserve, dans l'ordre de leurs chaises — pour un tour que la
|
||||
* soirée n'a plus, celles qu'assises y lit. null sans retenu, ou pour un
|
||||
* retenu fautif, qui ne se réécrit pas.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {Map<number, number>} changements identifiant de table → places
|
||||
* @returns {null|Array<{table: number, rang: number, sieges: number,
|
||||
* tours: Array<{tour: number, liste: Array<number|null>, verses: number[], personnes: number[]}>}>}
|
||||
*/
|
||||
function versements(charge, changements) {
|
||||
const { retenu } = charge;
|
||||
if (retenu === null || fautes(retenu).length > 0) return null;
|
||||
const hypothese = avecCapacites(charge, changements);
|
||||
const ids = presents(charge);
|
||||
const actives = reservationsActives(charge);
|
||||
const touchees = charge.tables
|
||||
.filter(({ id }) => changements.has(id) && retenu.tables.includes(id))
|
||||
.map(({ id }) => ({ table: id, rang: retenu.tables.indexOf(id), sieges: changements.get(id), tours: [] }));
|
||||
for (let tour = 1; tour <= retenu.tours; tour += 1) {
|
||||
const visible = tour <= charge.evenement.tours;
|
||||
const etatAvant = visible ? etatPlaces(charge, { tour }) : null;
|
||||
// Sièges attribués, ce qui reste ne se lit pas dans le plan d'après.
|
||||
const etatApres = visible && !retenu.siegesAttribues ? etatPlaces(hypothese, { tour }) : null;
|
||||
for (const touchee of touchees) {
|
||||
const { table, rang, sieges } = touchee;
|
||||
const liste = retenu.placement[tour - 1].sieges[rang];
|
||||
const avant = assises(charge, etatAvant, table, tour);
|
||||
let reste;
|
||||
if (retenu.siegesAttribues) {
|
||||
// Le siège est le rang : un rang au-delà des places d'aujourd'hui ne
|
||||
// se montre pas, et une hausse ne l'assied pas.
|
||||
const montres = capacite(charge, tableDe(charge, table));
|
||||
const limite = Math.min(sieges, montres);
|
||||
reste = ramenerReservees(liste, limite, montres, reserveesAuTour(actives, table, tour), ids);
|
||||
} else {
|
||||
const apres = new Set(assises(hypothese, etatApres, table, tour));
|
||||
const gardees = new Set(avant.filter((id) => apres.has(id)));
|
||||
reste = liste.filter((id) => !ids.has(id) || gardees.has(id));
|
||||
const absents = reste.filter((id) => !ids.has(id)).sort(croissant);
|
||||
// Les présents gardés tiennent dans sieges : ôter les absents suffit.
|
||||
while (reste.length > sieges && absents.length > 0) {
|
||||
const absent = absents.shift();
|
||||
reste = reste.filter((id) => id !== absent);
|
||||
}
|
||||
}
|
||||
const restants = new Set(reste);
|
||||
const verses = liste.filter((id) => id !== null && !restants.has(id));
|
||||
const personnes = avant.filter((id) => !restants.has(id));
|
||||
touchee.tours.push({ tour, liste: reste, verses, personnes });
|
||||
}
|
||||
}
|
||||
return touchees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le retenu de la charge aux nouvelles capacités (§ 6.2, § 9) : à chaque
|
||||
* tour, ce qui ne tient plus versé dans la réserve, puis la capacité de
|
||||
* chaque table touchée écrite dans retenu.capacites. Un objet neuf ; le
|
||||
* retenu reçu tel quel sans retenu, pour un retenu fautif, ou quand il ne
|
||||
* déclare aucune table touchée.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {Map<number, number>} changements identifiant de table → places
|
||||
* @returns {import('../stockage/types.js').Retenu|null}
|
||||
*/
|
||||
export function retenuAuxCapacites(charge, changements) {
|
||||
const touchees = versements(charge, changements);
|
||||
if (touchees === null || touchees.length === 0) return charge.retenu;
|
||||
const retenu = structuredClone(charge.retenu);
|
||||
for (const { rang, sieges, tours } of touchees) {
|
||||
for (const { tour, liste, verses } of tours) {
|
||||
const etage = retenu.placement[tour - 1];
|
||||
etage.sieges[rang] = liste;
|
||||
etage.reserve = [...etage.reserve, ...verses].sort(croissant);
|
||||
}
|
||||
retenu.capacites[rang] = sieges;
|
||||
}
|
||||
return retenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les refus et les avertissements de plusieurs capacités à la fois, chacune
|
||||
* contrôlée comme le ferait controlerCapacite, sur la même charge : les
|
||||
* tables ne partagent aucune chaise. Les tables de changements dans l'ordre
|
||||
* de charge.tables ; refus, le premier de chaque table qui refuse ;
|
||||
* avertissements, ceux des tables qui ne refusent pas.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {Map<number, number>} changements identifiant de table → places
|
||||
* @param {{origine: 'champ'|'poignee'}} options
|
||||
* @returns {{refus: Array<{table: number, code: string, details: Object}>,
|
||||
* avertissements: Array<{table: number, code: string, details: Object}>}}
|
||||
* @throws {TypeError} une table inconnue, des places qui ne sont pas un entier, une origine inconnue
|
||||
*/
|
||||
export function controlerCapacites(charge, changements, { origine }) {
|
||||
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
|
||||
for (const [idTable, sieges] of changements) {
|
||||
tableDe(charge, idTable);
|
||||
if (!Number.isSafeInteger(sieges)) throw new TypeError(`places : entier attendu, reçu ${JSON.stringify(sieges)}`);
|
||||
}
|
||||
const ordre = charge.tables.filter(({ id }) => changements.has(id)).map(({ id }) => id);
|
||||
const refus = [];
|
||||
const acceptees = new Map();
|
||||
for (const idTable of ordre) {
|
||||
const premier = refusDeCapacite(charge, idTable, changements.get(idTable), origine);
|
||||
if (premier === null) acceptees.set(idTable, changements.get(idTable));
|
||||
else refus.push({ table: idTable, ...premier });
|
||||
}
|
||||
const parTable = new Map(ordre.map((id) => [id, []]));
|
||||
for (const { table, tours } of versements(charge, acceptees) ?? []) {
|
||||
for (const { tour, personnes } of tours) {
|
||||
if (personnes.length > 0) parTable.get(table).push(avertissement('SURNUMERAIRES', { table, tour, personnes }));
|
||||
}
|
||||
}
|
||||
const seuil = constante(SEUIL_LISIBLE.nom);
|
||||
const avertissements = [];
|
||||
for (const [idTable, sieges] of acceptees) {
|
||||
for (const { code, details } of parTable.get(idTable)) avertissements.push({ table: idTable, code, details });
|
||||
if (sieges > seuil) avertissements.push({ table: idTable, code: 'AU_DELA_LISIBLE', details: { seuil } });
|
||||
}
|
||||
return { refus, avertissements };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le contrôle d'une capacité (§ 6.2, § 7.1), sans rien écrire : sieges
|
||||
* places pour la table idTable, saisies au champ ou tirées à la poignée.
|
||||
* refus, le premier dans l'ordre de l'en-tête du module, ou null ;
|
||||
* avertissements, quand il n'y a pas de refus : SURNUMERAIRES {table, tour,
|
||||
* personnes}, un par tour du retenu — un tour que la soirée n'a plus
|
||||
* compris — où il verse dans la réserve des personnes que le plan montrait
|
||||
* assises à la table, nommées dans
|
||||
* l'ordre de leurs chaises — la poignée, qui refuse sous l'occupation, n'en
|
||||
* verse aucune ; puis AU_DELA_LISIBLE {seuil} au-delà de
|
||||
* SEUIL_SIEGES_LISIBLES places.
|
||||
*
|
||||
* Refus : MOINS_DE_DEUX {}, remède null ; SOUS_RESERVATIONS {table, n,
|
||||
* tour} et SOUS_OCCUPES {table, n, tour}, tour le premier qui atteint n,
|
||||
* remède { geste: 'afficherTour', tour, proposition: null } ;
|
||||
* SIEGES_RETIRES {table, sieges, titres?} — chaque siège retiré qui porte un
|
||||
* titre {siege, cause: 'SIEGE_TITRE', libelle} ou une réservation {siege,
|
||||
* cause: 'SIEGE_RESERVE', participant, suspendue}, par numéro décroissant ;
|
||||
* titres, le compte des titres, sous des sièges non attribués seulement —,
|
||||
* remède celui du premier siège listé, retirerTitre ou retirerReservation.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {number} idTable
|
||||
* @param {number} sieges
|
||||
* @param {{origine: 'champ'|'poignee'}} options
|
||||
* @returns {{refus: {code: string, details: Object}|null, avertissements: Array<{code: string, details: Object}>}}
|
||||
* @throws {TypeError} une table inconnue, des places qui ne sont pas un entier, une origine inconnue
|
||||
*/
|
||||
export function controlerCapacite(charge, idTable, sieges, { origine }) {
|
||||
const { refus, avertissements } = controlerCapacites(charge, new Map([[idTable, sieges]]), { origine });
|
||||
if (refus.length > 0) return { refus: { code: refus[0].code, details: refus[0].details }, avertissements: [] };
|
||||
return { refus: null, avertissements: avertissements.map(({ code, details }) => ({ code, details })) };
|
||||
}
|
||||
284
src/application/capacite.test.js
Normal file
284
src/application/capacite.test.js
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du contrôle de capacité (§ 6.2, § 7.1, § 14.1) : la lecture d'un
|
||||
// entier saisi, puis les refus d'une capacité — moins de deux places, sous
|
||||
// les réservations, sièges retirés qui portent une saisie, sous l'occupation
|
||||
// pour la poignée — et l'avertissement de lisibilité. Les noms d'épreuve sont
|
||||
// inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { serialiser } from '../stockage/canonique.js';
|
||||
import { analyser, creerCharge } from '../stockage/document.js';
|
||||
import { controlerCapacite, lireEntier } from './capacite.js';
|
||||
import { COMMANDES } from './commandes.js';
|
||||
import { libelle } from './libelles.js';
|
||||
|
||||
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Bruyère', 'Sarcelle', 'Ancolie', 'Mélèze', 'Quenouille', 'Pivoine'];
|
||||
|
||||
const personne = (id, autres = {}) => ({
|
||||
id,
|
||||
nom: NOMS[(id - 1) % NOMS.length],
|
||||
prenom: null,
|
||||
appartenance: null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...autres,
|
||||
});
|
||||
|
||||
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
|
||||
|
||||
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
|
||||
|
||||
// Une charge d'une table d'identifiant 1, numéro 1, qui suit un défaut de
|
||||
// huit places ; n personnes, R tours.
|
||||
function chargeUneTable({ n = 8, tours = 3, attribuerSieges = false, reservations = [], titres = [], exclus = [] } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-capacite', nom: 'Soirée des Merles', siegesParDefaut: 8, tours });
|
||||
charge.reglages.attribuerSieges = attribuerSieges;
|
||||
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: exclus.includes(rang + 1) }));
|
||||
charge.tables = [{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }];
|
||||
charge.reservations = reservations;
|
||||
charge.titres = titres;
|
||||
charge.prochainsIds = { participant: n + 1, table: 2, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La proposition 1, retenue, dont la table 1 assied listes[r] au tour r + 1 ;
|
||||
// les autres déclarés attendent en réserve.
|
||||
function retenir(charge, listes, { siegesAttribues = charge.reglages.attribuerSieges } = {}) {
|
||||
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id }) => id);
|
||||
const plan = {
|
||||
siegesAttribues,
|
||||
tables: [1],
|
||||
capacites: [8],
|
||||
tours: listes.length,
|
||||
participants,
|
||||
placement: listes.map((liste) => ({ sieges: [liste], reserve: participants.filter((id) => !liste.includes(id)) })),
|
||||
};
|
||||
charge.propositions = [{ id: 1, graine: 7, arret: 40, historique: 1000, produitVersion: 'essai', ...plan }];
|
||||
charge.retenu = { proposition: 1, ...structuredClone(plan) };
|
||||
charge.evenement.etat = 'retenu';
|
||||
charge.prochainsIds.proposition = 2;
|
||||
return charge;
|
||||
}
|
||||
|
||||
const champ = (charge, sieges) => controlerCapacite(charge, 1, sieges, { origine: 'champ' });
|
||||
const poignee = (charge, sieges) => controlerCapacite(charge, 1, sieges, { origine: 'poignee' });
|
||||
|
||||
describe("capacité : la lecture d'un entier saisi (§ 6.2)", () => {
|
||||
test('un entier sans signe ni décimale se lit ; tout autre texte refuse NON_ENTIER, remède null', () => {
|
||||
assert.deepEqual(lireEntier('2'), { valeur: 2 });
|
||||
assert.deepEqual(lireEntier('0'), { valeur: 0 });
|
||||
assert.deepEqual(lireEntier(' 12 '), { valeur: 12 });
|
||||
assert.deepEqual(lireEntier(9), { valeur: 9 });
|
||||
const refusees = ['-3', '2.5', '2,5', '8abc', 'huit', '', '+4', '1e3', '9007199254740993', -3, 2.5];
|
||||
assert.equal(refusees.length, 11);
|
||||
for (const saisie of refusees) {
|
||||
assert.deepEqual(lireEntier(saisie), { refus: { code: 'NON_ENTIER', details: { saisie, remede: null } } }, String(saisie));
|
||||
}
|
||||
});
|
||||
|
||||
test("une saisie qui n'est ni un texte ni un nombre est une faute du code", () => {
|
||||
assert.throws(() => lireEntier(null), TypeError);
|
||||
assert.throws(() => lireEntier(undefined), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capacité : les refus d'une capacité (§ 6.2)", () => {
|
||||
test('moins de deux places : MOINS_DE_DEUX, remède null ; deux places passent', () => {
|
||||
const charge = chargeUneTable();
|
||||
for (const sieges of [1, 0]) {
|
||||
assert.deepEqual(champ(charge, sieges).refus, { code: 'MOINS_DE_DEUX', details: { remede: null } });
|
||||
}
|
||||
assert.equal(champ(charge, 2).refus, null);
|
||||
});
|
||||
|
||||
test('sous les réservations : le plus grand compte de reservationsParTour, au premier tour qui l\'atteint', () => {
|
||||
const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
|
||||
assert.deepEqual(champ(cinq, 4).refus, {
|
||||
code: 'SOUS_RESERVATIONS',
|
||||
details: { table: 1, n: 5, tour: 1, remede: afficherTour(1) },
|
||||
});
|
||||
assert.equal(champ(cinq, 5).refus, null);
|
||||
|
||||
// Trois réservations « tour », chacune à un tour différent : une à la fois.
|
||||
const parTour = chargeUneTable({ reservations: [resa(1, 1, null, 1), resa(2, 1, null, 2), resa(3, 1, null, 3)] });
|
||||
assert.equal(champ(parTour, 2).refus, null);
|
||||
// Deux au tour 3 : le premier tour qui atteint deux est le 3.
|
||||
parTour.reservations.push(resa(4, 1, null, 3));
|
||||
assert.deepEqual(champ(parTour, 1).refus.code, 'MOINS_DE_DEUX');
|
||||
parTour.reservations.push(resa(5, 1, null, 3));
|
||||
assert.deepEqual(champ(parTour, 2).refus, { code: 'SOUS_RESERVATIONS', details: { table: 1, n: 3, tour: 3, remede: afficherTour(3) } });
|
||||
|
||||
// La réservation « tous » d'une personne exclue est suspendue : elle ne compte pas.
|
||||
const exclue = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)), exclus: [5] });
|
||||
assert.equal(champ(exclue, 4).refus, null);
|
||||
});
|
||||
|
||||
test('sièges retirés : réservation au siège 7 et titre au siège 8, listés par numéro décroissant, remède du premier', () => {
|
||||
const titre = { table: 1, siege: 8, libelle: 'animateur' };
|
||||
const charge = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], titres: [titre] });
|
||||
const reservee = { siege: 7, cause: 'SIEGE_RESERVE', participant: 1, suspendue: false };
|
||||
const titree = { siege: 8, cause: 'SIEGE_TITRE', libelle: 'animateur' };
|
||||
assert.deepEqual(champ(charge, 6).refus, {
|
||||
code: 'SIEGES_RETIRES',
|
||||
details: { table: 1, sieges: [titree, reservee], remede: { commande: 'retirerTitre', arguments: { table: 1, siege: 8 } } },
|
||||
});
|
||||
assert.deepEqual(champ(charge, 7).refus.details.sieges, [titree]);
|
||||
|
||||
// Sans le titre : la réservation seule, et son remède s'exécute tel quel.
|
||||
const sansTitre = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)] });
|
||||
const refus = champ(sansTitre, 6).refus;
|
||||
const remede = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tous', tour: null } };
|
||||
assert.deepEqual(refus, { code: 'SIEGES_RETIRES', details: { table: 1, sieges: [reservee], remede } });
|
||||
const leve = COMMANDES.get(remede.commande)(sansTitre, remede.arguments);
|
||||
assert.equal(champ(leve.charge, 6).refus, null);
|
||||
|
||||
// Une réservation « tour » : le remède porte sa portée et son tour, et
|
||||
// s'exécute tel quel.
|
||||
const duTour = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7, 2)] });
|
||||
const remedeDuTour = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tour', tour: 2 } };
|
||||
assert.deepEqual(champ(duTour, 6).refus, { code: 'SIEGES_RETIRES', details: { table: 1, sieges: [reservee], remede: remedeDuTour } });
|
||||
const leveDuTour = COMMANDES.get(remedeDuTour.commande)(duTour, remedeDuTour.arguments);
|
||||
assert.deepEqual(leveDuTour.charge.reservations, []);
|
||||
assert.equal(champ(leveDuTour.charge, 6).refus, null);
|
||||
|
||||
// La personne du siège 7 exclue : sa réservation suspendue reste listée.
|
||||
const suspendue = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], exclus: [1] });
|
||||
assert.deepEqual(champ(suspendue, 6).refus.details.sieges, [{ ...reservee, suspendue: true }]);
|
||||
|
||||
// Sièges non attribués : la même liste, et le compte des titres.
|
||||
const enTable = chargeUneTable({ reservations: [resa(1, 1, 7)], titres: [titre] });
|
||||
assert.deepEqual(champ(enTable, 6).refus.details, {
|
||||
table: 1,
|
||||
sieges: [titree, reservee],
|
||||
titres: 1,
|
||||
remede: { commande: 'retirerTitre', arguments: { table: 1, siege: 8 } },
|
||||
});
|
||||
});
|
||||
|
||||
test('deux titres à un même siège se listent par libellé ; le texte du refus les énonce dans cet ordre', () => {
|
||||
const titres = [{ table: 1, siege: 8, libelle: 'animateur' }, { table: 1, siege: 8, libelle: 'accueil' }];
|
||||
const charge = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], titres, exclus: [1] });
|
||||
const { refus } = champ(charge, 6);
|
||||
assert.deepEqual(refus.details.sieges.map(({ siege, libelle: texte }) => [siege, texte ?? null]), [[8, 'accueil'], [8, 'animateur'], [7, null]]);
|
||||
assert.equal(
|
||||
libelle(refus.code, refus.details),
|
||||
'Réduire la table retire des sièges qui portent une saisie : le siège 8 porte le titre « accueil », ' +
|
||||
'le siège 8 porte le titre « animateur » et le siège 7 est réservé, par une réservation suspendue.',
|
||||
);
|
||||
const enTable = chargeUneTable({ reservations: [resa(1, 1, 7)], titres });
|
||||
const refusEnTable = champ(enTable, 6).refus;
|
||||
assert.equal(refusEnTable.details.titres, 2);
|
||||
assert.equal(
|
||||
libelle(refusEnTable.code, refusEnTable.details),
|
||||
'Réduire la table retire des sièges qui portent une saisie : le siège 7 est réservé et 2 sièges portent un titre.',
|
||||
);
|
||||
});
|
||||
|
||||
test('chaque refus et chaque avertissement de capacité se dit en français, ses nombres compris', () => {
|
||||
const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
|
||||
const textes = [
|
||||
libelle('MOINS_DE_DEUX', champ(cinq, 1).refus.details),
|
||||
libelle('SOUS_RESERVATIONS', champ(cinq, 4).refus.details),
|
||||
libelle('NON_ENTIER', lireEntier('8abc').refus.details),
|
||||
libelle('NON_ENTIER', lireEntier('').refus.details),
|
||||
libelle('AU_DELA_LISIBLE', { seuil: 16 }),
|
||||
libelle('SOUS_OCCUPES', { table: 1, n: 7, tour: 3 }),
|
||||
libelle('SURNUMERAIRES', { table: 1, tour: 2, personnes: [4] }),
|
||||
libelle('SURNUMERAIRES', { table: 1, tour: 2, personnes: [4, 5] }),
|
||||
libelle('MOINS_D_UN_TOUR', { tours: 0 }),
|
||||
];
|
||||
assert.deepEqual(textes, [
|
||||
'Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.',
|
||||
'La table porte 5 réservations au tour 1 : elle ne descend pas sous 5 places.',
|
||||
'« 8abc » n\'est pas un nombre entier : un nombre entier, sans signe ni décimale, est attendu.',
|
||||
'La saisie est vide : un nombre entier, sans signe ni décimale, est attendu.',
|
||||
"Au-delà de 16 places, la liste des noms de la table cesse d'être lisible sur le plan.",
|
||||
'Au tour 3, la table occupe 7 places : la poignée ne descend pas en deçà.',
|
||||
"Au tour 2, 1 personne passe dans la réserve : la table n'a plus de chaise pour elle.",
|
||||
"Au tour 2, 2 personnes passent dans la réserve : la table n'a plus de chaise pour elles.",
|
||||
'Une soirée compte au moins un tour.',
|
||||
]);
|
||||
});
|
||||
|
||||
test("sièges retirés : la charge réduite sans ce refus, suspendue ou en mode table, est celle que l'analyse refuse", () => {
|
||||
for (const charge of [
|
||||
chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], exclus: [1] }),
|
||||
chargeUneTable({ reservations: [resa(1, 1, 7)] }),
|
||||
]) {
|
||||
assert.equal(champ(charge, 6).refus.code, 'SIEGES_RETIRES');
|
||||
const reduite = structuredClone(charge);
|
||||
reduite.tables[0].sieges = 6;
|
||||
assert.throws(
|
||||
() => analyser(serialiser(reduite, { revision: 1, produitVersion: 'essai' })),
|
||||
(erreur) => erreur.code === 'ETAT_ILLISIBLE' && erreur.details.raison === 'REFERENCE',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('une table inconnue, ou une capacité qui n\'est pas un entier, est une faute du code', () => {
|
||||
const charge = chargeUneTable();
|
||||
assert.throws(() => controlerCapacite(charge, 9, 6, { origine: 'champ' }), TypeError);
|
||||
assert.throws(() => controlerCapacite(charge, 1, '6', { origine: 'champ' }), TypeError);
|
||||
assert.throws(() => controlerCapacite(charge, 1, 6, { origine: 'clavier' }), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capacité : la poignée, sous l'occupation de tous les tours (§ 7.1)", () => {
|
||||
test('sièges non attribués : 5, 5, 7 et 5 personnes aux tours 1 à 4 ; 6 refuse SOUS_OCCUPES {n: 7, tour: 3}', () => {
|
||||
const cinq = [1, 2, 3, 4, 5];
|
||||
const charge = retenir(chargeUneTable({ n: 7, tours: 4 }), [cinq, cinq, [1, 2, 3, 4, 5, 6, 7], cinq]);
|
||||
assert.deepEqual(poignee(charge, 6).refus, {
|
||||
code: 'SOUS_OCCUPES',
|
||||
details: { table: 1, n: 7, tour: 3, remede: afficherTour(3) },
|
||||
});
|
||||
assert.equal(poignee(charge, 7).refus, null);
|
||||
// Le champ, lui, accepte et avertit.
|
||||
assert.equal(champ(charge, 6).refus, null);
|
||||
assert.deepEqual(poignee(charge, 1).refus, { code: 'MOINS_DE_DEUX', details: { remede: null } });
|
||||
});
|
||||
|
||||
test("sièges attribués : huit occupants au tour 3 refusent 7 ; une réservation ou un titre au siège 8 refusent SIEGES_RETIRES d'abord", () => {
|
||||
const cinq = [1, 2, 3, 4, 5];
|
||||
const huit = retenir(chargeUneTable({ n: 8, attribuerSieges: true }), [cinq, cinq, [1, 2, 3, 4, 5, 6, 7, 8]]);
|
||||
assert.deepEqual(poignee(huit, 7).refus, { code: 'SOUS_OCCUPES', details: { table: 1, n: 8, tour: 3, remede: afficherTour(3) } });
|
||||
|
||||
const assise = [1, 2, 3, 4, 5, null, null, 6];
|
||||
const reservee = retenir(chargeUneTable({ n: 6, attribuerSieges: true, reservations: [resa(6, 1, 8)] }), [assise, assise, assise]);
|
||||
const refus = poignee(reservee, 7).refus;
|
||||
assert.equal(refus.code, 'SIEGES_RETIRES');
|
||||
assert.deepEqual(refus.details.sieges, [{ siege: 8, cause: 'SIEGE_RESERVE', participant: 6, suspendue: false }]);
|
||||
|
||||
const titree = retenir(chargeUneTable({ n: 5, attribuerSieges: true, titres: [{ table: 1, siege: 8, libelle: 'accueil' }] }), [cinq, cinq, cinq]);
|
||||
assert.deepEqual(poignee(titree, 7).refus.details.sieges, [{ siege: 8, cause: 'SIEGE_TITRE', libelle: 'accueil' }]);
|
||||
|
||||
// Six occupants, deux chaises vides avant le sixième, aucune saisie : le
|
||||
// plus haut numéro occupé, 8, compte plus que les six occupants.
|
||||
const creuse = retenir(chargeUneTable({ n: 6, attribuerSieges: true }), [assise, assise, assise]);
|
||||
assert.deepEqual(poignee(creuse, 7).refus, { code: 'SOUS_OCCUPES', details: { table: 1, n: 8, tour: 1, remede: afficherTour(1) } });
|
||||
});
|
||||
|
||||
test("la poignée ne reçoit aucun tour affiché : l'occupation se lit à chaque tour du retenu", () => {
|
||||
const cinq = [1, 2, 3, 4, 5];
|
||||
const charge = retenir(chargeUneTable({ n: 7, tours: 4 }), [cinq, cinq, cinq, [1, 2, 3, 4, 5, 6, 7]]);
|
||||
assert.deepEqual(poignee(charge, 6).refus.details, { table: 1, n: 7, tour: 4, remede: afficherTour(4) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('capacité : la lisibilité (§ 6.2, § 14.1)', () => {
|
||||
test('S + 1 places avertissent AU_DELA_LISIBLE {seuil: S} ; S places, non', () => {
|
||||
const seuil = constante('SEUIL_SIEGES_LISIBLES');
|
||||
assert.ok(Number.isInteger(seuil) && seuil > 2);
|
||||
const charge = chargeUneTable();
|
||||
for (const origine of ['champ', 'poignee']) {
|
||||
const au = controlerCapacite(charge, 1, seuil + 1, { origine });
|
||||
assert.equal(au.refus, null);
|
||||
assert.deepEqual(au.avertissements, [{ code: 'AU_DELA_LISIBLE', details: { seuil } }]);
|
||||
assert.deepEqual(controlerCapacite(charge, 1, seuil, { origine }).avertissements, []);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -634,6 +634,14 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
|
|||
['deplacerDansRetenu', { tour: 1, participant: autre, table: tableAutre, siege: null }],
|
||||
['echangerDansRetenu', { tour: 1, a: voisin, b: autre }],
|
||||
['retirerDuTour', { tour: 1, participant: autre }],
|
||||
['creerTable', { forme: 'carree' }],
|
||||
['supprimerTable', { id: 2 }],
|
||||
['deplacerTable', { id: 1, position: { x: 40, y: 40 } }],
|
||||
['changerPlacesTable', { id: 2, sieges: 3, origine: 'champ' }],
|
||||
['realignerTable', { id: 1 }],
|
||||
['changerDefaut', { sieges: 3, realigner: false }],
|
||||
['changerTours', { tours: 3 }],
|
||||
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,266 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le registre des commandes du mobilier — tables, sièges, tours (§ 6, § 8.2),
|
||||
// que commandes.js réunit aux autres. Ses imports n'atteignent jamais
|
||||
// commandes.js : le registre réuni dépend de lui, non l'inverse.
|
||||
// Le registre des commandes du mobilier — tables, sièges, tours, réglages
|
||||
// (§ 4, § 6, § 7.1, § 8.2, § 9), que commandes.js réunit aux autres. Ses
|
||||
// imports n'atteignent jamais commandes.js : le registre réuni dépend de lui,
|
||||
// non l'inverse.
|
||||
//
|
||||
// Chaque commande suit le contrat de commandes.js : une fonction pure
|
||||
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
|
||||
// intacte ; un refus lève ErreurCommande(code, details), details.remede
|
||||
// compris, calculé avant toute modification ; un argument de forme fausse ou
|
||||
// une table inconnue lèvent TypeError, que l'interface n'offre jamais. Un
|
||||
// plan bloqué refuse tout (PLAN_BLOQUE). Une saisie de places ou de tours se
|
||||
// lit par lireEntier, la seule lecture d'un entier saisi.
|
||||
//
|
||||
// Une table a un identifiant, tiré de prochainsIds.table, qui ne revient
|
||||
// jamais, et un numéro affiché, le plus grand plus un, qui ne se renumérote
|
||||
// pas (§ 4). Les capacités passent par controlerCapacite (capacite.js) ; le
|
||||
// retenu les suit, ses surnuméraires versés en réserve, et une table créée y
|
||||
// entre, vide à chaque tour : il ne gagne aucune raison de dérive. Une
|
||||
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
|
||||
// table et changer les tours laissent propositions et retenu en dérive.
|
||||
import { caseLibre } from '../geometrie/tables.js';
|
||||
import { SCHEMA, capacite } from '../stockage/document.js';
|
||||
import { fautes } from '../stockage/placements.js';
|
||||
import { bilanChangementDefaut } from './bilans.js';
|
||||
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
|
||||
import { ErreurCommande, refus } from './erreurs.js';
|
||||
import { libelle } from './libelles.js';
|
||||
import { leverRefus } from './pose.js';
|
||||
|
||||
const FORMES = new Set(['ronde', 'carree']);
|
||||
const ORIGINES = new Set(['champ', 'poignee']);
|
||||
|
||||
// Le plus grand identifiant de table qu'admet la règle du schéma : le
|
||||
// compteur, qui la suit, n'aurait plus de valeur au-delà de lui.
|
||||
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||||
const IDENTIFIANT_MAX = regleDuChamp(regleDuChamp(regleDuChamp(SCHEMA, 'charge'), 'tables').element, 'id').max;
|
||||
|
||||
// Les réglages que modifierReglage change : un interrupteur, ou un entier ≥ 1
|
||||
// de la génération (§ 5.3, § 5.10).
|
||||
const INTERRUPTEURS = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']);
|
||||
const ENTIERS_DE_GENERATION = new Set(['generation.nombre', 'generation.arret', 'generation.historique']);
|
||||
|
||||
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
|
||||
function exigerModifiable(charge) {
|
||||
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
|
||||
}
|
||||
|
||||
// L'enregistrement de la table d'identifiant id : une table inconnue est une
|
||||
// faute de l'appelant.
|
||||
function tableDe(charge, id) {
|
||||
const table = charge.tables.find((candidate) => candidate.id === id);
|
||||
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
|
||||
return table;
|
||||
}
|
||||
|
||||
// La valeur d'un entier saisi, ou le refus NON_ENTIER levé.
|
||||
function entierSaisi(saisie) {
|
||||
const lu = lireEntier(saisie);
|
||||
if (lu.refus) throw new ErreurCommande(lu.refus.code, lu.refus.details);
|
||||
return lu.valeur;
|
||||
}
|
||||
|
||||
// La charge reçue, copiée, chaque table de changements (Map identifiant →
|
||||
// sieges) à son nombre de sièges, null pour une table qui suit le défaut, et
|
||||
// le retenu aux capacités qui en résultent.
|
||||
function avecSieges(charge, changements, defaut = charge.evenement.siegesParDefaut) {
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.evenement.siegesParDefaut = defaut;
|
||||
for (const table of suivante.tables) if (changements.has(table.id)) table.sieges = changements.get(table.id);
|
||||
const capacites = new Map(suivante.tables.map((table) => [table.id, capacite(suivante, table)]));
|
||||
const touchees = new Map(
|
||||
charge.tables
|
||||
.filter((table) => changements.has(table.id) || capacites.get(table.id) !== capacite(charge, table))
|
||||
.map(({ id }) => [id, capacites.get(id)]),
|
||||
);
|
||||
suivante.retenu = structuredClone(retenuAuxCapacites(charge, touchees));
|
||||
return suivante;
|
||||
}
|
||||
|
||||
// Le rendu d'un contrôle de capacité accepté : ses avertissements, ou son
|
||||
// refus levé.
|
||||
function exigerCapacite(charge, id, sieges, origine) {
|
||||
const { refus: refuse, avertissements } = controlerCapacite(charge, id, sieges, { origine });
|
||||
if (refuse !== null) throw new ErreurCommande(refuse.code, refuse.details);
|
||||
return avertissements;
|
||||
}
|
||||
|
||||
/**
|
||||
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant
|
||||
* prochainsIds.table, numérotée après la plus grande, posée sur la première
|
||||
* case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa
|
||||
* capacité et une liste vide à chaque tour.
|
||||
*/
|
||||
function creerTable(charge, { forme } = {}) {
|
||||
exigerModifiable(charge);
|
||||
if (!FORMES.has(forme)) throw new TypeError(`forme : 'ronde' ou 'carree' attendue, reçu ${JSON.stringify(forme)}`);
|
||||
const id = charge.prochainsIds.table;
|
||||
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
|
||||
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
|
||||
const position = caseLibre(charge.tables.map((table) => table.position));
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.tables.push({ id, numero, sieges: null, forme, position });
|
||||
suivante.prochainsIds.table = id + 1;
|
||||
const { retenu } = suivante;
|
||||
if (retenu !== null && fautes(retenu).length === 0) {
|
||||
retenu.tables.push(id);
|
||||
retenu.capacites.push(charge.evenement.siegesParDefaut);
|
||||
for (const etage of retenu.placement) etage.sieges.push([]);
|
||||
}
|
||||
return { charge: suivante, libelle: libelle('ENTREE_CREER_TABLE', { numero }), avertissements: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* supprimerTable {id} (§ 6.2, § 9) : la table part, et avec elle, dans la
|
||||
* même entrée, ses réservations — suspendues comprises — et ses titres ; les
|
||||
* autres tables gardent leur numéro, propositions et retenu restent, en
|
||||
* dérive TABLE_SUPPRIMEE.
|
||||
*/
|
||||
function supprimerTable(charge, { id } = {}) {
|
||||
exigerModifiable(charge);
|
||||
const table = tableDe(charge, id);
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.tables = suivante.tables.filter((candidate) => candidate.id !== id);
|
||||
suivante.reservations = suivante.reservations.filter((reservation) => reservation.table !== id);
|
||||
suivante.titres = suivante.titres.filter((titre) => titre.table !== id);
|
||||
const details = {
|
||||
numero: table.numero,
|
||||
reservations: charge.reservations.length - suivante.reservations.length,
|
||||
titres: charge.titres.length - suivante.titres.length,
|
||||
};
|
||||
return { charge: suivante, libelle: libelle('ENTREE_SUPPRIMER_TABLE', details), avertissements: [] };
|
||||
}
|
||||
|
||||
/** deplacerTable {id, position: {x, y}} (§ 7.1) : la position du centre, arrondie au centimètre. */
|
||||
function deplacerTable(charge, { id, position } = {}) {
|
||||
exigerModifiable(charge);
|
||||
const table = tableDe(charge, id);
|
||||
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) {
|
||||
throw new TypeError(`position : {x, y} finis attendus, reçu ${JSON.stringify(position)}`);
|
||||
}
|
||||
const suivante = structuredClone(charge);
|
||||
// + 0 écrit 0 là où l'arrondi d'une petite valeur négative rend −0.
|
||||
suivante.tables.find((candidate) => candidate.id === id).position = { x: Math.round(position.x) + 0, y: Math.round(position.y) + 0 };
|
||||
return { charge: suivante, libelle: libelle('ENTREE_DEPLACER_TABLE', { numero: table.numero }), avertissements: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* changerPlacesTable {id, sieges, origine: 'champ'|'poignee'} (§ 6.2, § 7.1) :
|
||||
* la table est surchargée à sieges places, lues par lireEntier, contrôlées
|
||||
* par controlerCapacite selon leur origine ; le retenu suit.
|
||||
*/
|
||||
function changerPlacesTable(charge, { id, sieges: saisie, origine } = {}) {
|
||||
exigerModifiable(charge);
|
||||
const table = tableDe(charge, id);
|
||||
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
|
||||
const sieges = entierSaisi(saisie);
|
||||
const avertissements = exigerCapacite(charge, id, sieges, origine);
|
||||
return {
|
||||
charge: avecSieges(charge, new Map([[id, sieges]])),
|
||||
libelle: libelle('ENTREE_CHANGER_PLACES_TABLE', { numero: table.numero, sieges }),
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* realignerTable {id} (§ 6.1) : la table suit de nouveau le défaut ; une
|
||||
* table surchargée au-dessus de lui subit une réduction, sous la règle du
|
||||
* champ (§ 6.2). Le retenu suit.
|
||||
*/
|
||||
function realignerTable(charge, { id } = {}) {
|
||||
exigerModifiable(charge);
|
||||
const table = tableDe(charge, id);
|
||||
const sieges = charge.evenement.siegesParDefaut;
|
||||
const avertissements = exigerCapacite(charge, id, sieges, 'champ');
|
||||
return {
|
||||
charge: avecSieges(charge, new Map([[id, null]])),
|
||||
libelle: libelle('ENTREE_REALIGNER_TABLE', { numero: table.numero, sieges }),
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* changerDefaut {sieges, realigner: boolean} (§ 6.1) : le défaut change, et
|
||||
* avec lui chaque table qui le suit ; realigner, les surchargées le suivent
|
||||
* aussi. Une seule entrée. Chaque table touchée se contrôle avant
|
||||
* d'appliquer (bilanChangementDefaut) : un seul refus refuse le tout, le
|
||||
* premier levé, les suivants dans details.autres. Les avertissements sont
|
||||
* ceux du bilan ; AU_DELA_LISIBLE, de mêmes détails à chaque table, n'est
|
||||
* dit qu'une fois. Le retenu suit.
|
||||
*/
|
||||
function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
|
||||
exigerModifiable(charge);
|
||||
if (typeof realigner !== 'boolean') throw new TypeError(`realigner : booléen attendu, reçu ${JSON.stringify(realigner)}`);
|
||||
const sieges = entierSaisi(saisie);
|
||||
const bilan = bilanChangementDefaut(charge, sieges, { realigner });
|
||||
leverRefus(bilan.refus.map(({ code, details }) => ({ code, details })));
|
||||
const avertissements = [];
|
||||
for (const { code, details } of bilan.avertissements) {
|
||||
if (code === 'AU_DELA_LISIBLE' && avertissements.some((deja) => deja.code === code)) continue;
|
||||
avertissements.push({ code, details });
|
||||
}
|
||||
const realignees = realigner ? charge.tables.filter((table) => table.sieges !== null) : [];
|
||||
return {
|
||||
charge: avecSieges(charge, new Map(realignees.map((table) => [table.id, null])), sieges),
|
||||
libelle: libelle('ENTREE_CHANGER_DEFAUT', { sieges, realignees: realignees.length }),
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par
|
||||
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, remède null. Retirer des tours
|
||||
* détruit les réservations « tour désigné » au-delà, dans la même entrée ;
|
||||
* la portée « tous » reste, symbolique. Propositions et retenu restent, en
|
||||
* dérive TOURS_CHANGES, jamais tronqués.
|
||||
*/
|
||||
function changerTours(charge, { tours: saisie } = {}) {
|
||||
exigerModifiable(charge);
|
||||
const tours = entierSaisi(saisie);
|
||||
if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours });
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.evenement.tours = tours;
|
||||
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);
|
||||
return {
|
||||
charge: suivante,
|
||||
libelle: libelle('ENTREE_CHANGER_TOURS', { tours, retirees: charge.reservations.length - suivante.reservations.length }),
|
||||
avertissements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* modifierReglage {cle, valeur} (§ 5.3, § 15.4) : un interrupteur des
|
||||
* contraintes ou de l'attribution des sièges, un booléen ; ou generation.nombre,
|
||||
* generation.arret, generation.historique, un entier ≥ 1, que le contrôleur
|
||||
* de génération a lu et borné. Une autre clé, ou une autre valeur, est une
|
||||
* faute du code.
|
||||
*/
|
||||
function modifierReglage(charge, { cle, valeur } = {}) {
|
||||
exigerModifiable(charge);
|
||||
const suivante = structuredClone(charge);
|
||||
if (INTERRUPTEURS.has(cle)) {
|
||||
if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(valeur)}`);
|
||||
suivante.reglages[cle] = valeur;
|
||||
} else if (ENTIERS_DE_GENERATION.has(cle)) {
|
||||
if (!Number.isSafeInteger(valeur) || valeur < 1) throw new TypeError(`${cle} : entier ≥ 1 attendu, reçu ${JSON.stringify(valeur)}`);
|
||||
suivante.reglages.generation[cle.slice('generation.'.length)] = valeur;
|
||||
} else {
|
||||
throw new TypeError(`réglage inconnu : ${JSON.stringify(cle)}`);
|
||||
}
|
||||
return { charge: suivante, libelle: libelle('ENTREE_MODIFIER_REGLAGE', { cle, valeur }), avertissements: [] };
|
||||
}
|
||||
|
||||
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
|
||||
export const COMMANDES_MOBILIER = new Map();
|
||||
export const COMMANDES_MOBILIER = new Map([
|
||||
['creerTable', creerTable],
|
||||
['supprimerTable', supprimerTable],
|
||||
['deplacerTable', deplacerTable],
|
||||
['changerPlacesTable', changerPlacesTable],
|
||||
['realignerTable', realignerTable],
|
||||
['changerDefaut', changerDefaut],
|
||||
['changerTours', changerTours],
|
||||
['modifierReglage', modifierReglage],
|
||||
]);
|
||||
|
|
|
|||
870
src/application/commandes_mobilier.test.js
Normal file
870
src/application/commandes_mobilier.test.js
Normal file
|
|
@ -0,0 +1,870 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des commandes du mobilier (§ 4, § 4.3, § 5.9, § 6, § 7.1, § 8.2,
|
||||
// § 9, § 15.4) : tables, défaut, tours et réglages. Chaque commande acceptée
|
||||
// rend une charge que l'analyse relit et qui se normalise quand la charge
|
||||
// reçue se normalisait ; un refus ne change rien. Les gestes qui comptent
|
||||
// les entrées du journal, ou les avertissements que la séance ajoute,
|
||||
// passent par seance.executer sur le système de fichiers d'épreuve. Les noms
|
||||
// d'épreuve sont inventés ; la version vient du module engendré.
|
||||
import assert from 'node:assert/strict';
|
||||
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { caseLibre } from '../geometrie/tables.js';
|
||||
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { serialiser } from '../stockage/canonique.js';
|
||||
import { creer as creerDansLeDepot } from '../stockage/depot.js';
|
||||
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { lireJournal } from '../stockage/journal.js';
|
||||
import { SUFFIXES } from '../stockage/noms.js';
|
||||
import { derive, examiner, fautes } from '../stockage/placements.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { invariantViole } from './avertissements.js';
|
||||
import { COMMANDES, appliquerCommande } from './commandes.js';
|
||||
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
|
||||
import { ErreurCommande } from './erreurs.js';
|
||||
import { libelle } from './libelles.js';
|
||||
import { etatPlaces } from './places.js';
|
||||
import { creerSeance } from './seance.js';
|
||||
|
||||
const V1 = VERSION.affichee;
|
||||
const NOM = 'Soirée des Merles';
|
||||
const DOC = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' };
|
||||
|
||||
const NOMS = [
|
||||
['Ombrelle', 'Iris'],
|
||||
['Pervenche', 'Théo'],
|
||||
['Lacasse', 'Ondine'],
|
||||
['Grisaille', null],
|
||||
['Bruyère', 'Anouk'],
|
||||
['Sarcelle', 'Capucine'],
|
||||
['Ancolie', 'Amandine'],
|
||||
['Mélèze', 'Basile'],
|
||||
['Quenouille', 'Hortense'],
|
||||
['Pivoine', 'Gaspard'],
|
||||
];
|
||||
|
||||
const personne = (id, autres = {}) => ({
|
||||
id,
|
||||
nom: NOMS[(id - 1) % NOMS.length][0],
|
||||
prenom: NOMS[(id - 1) % NOMS.length][1],
|
||||
appartenance: null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...autres,
|
||||
});
|
||||
|
||||
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
|
||||
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);
|
||||
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
|
||||
|
||||
// Une charge : les tables données ({id, sieges}, numéro égal à l'identifiant
|
||||
// sauf indication), n personnes dont les exclus, R tours, un défaut.
|
||||
function chargeDe({ tables = [], n = 0, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-mobilier', nom: NOM, siegesParDefaut: defaut, tours });
|
||||
charge.reglages.attribuerSieges = attribuerSieges;
|
||||
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: exclus.includes(rang + 1) }));
|
||||
charge.tables = tables.map(({ id, numero = id, sieges = null }) => ({ id, numero, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.reservations = reservations;
|
||||
charge.titres = titres;
|
||||
charge.prochainsIds = { participant: n + 1, table: Math.max(0, ...tables.map(({ id }) => id)) + 1, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Ajoute la proposition id, ses listes dans l'ordre de charge.tables, et la
|
||||
// retient quand retenu est vrai.
|
||||
function placer(charge, listes, { id, retenu = false }) {
|
||||
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
|
||||
const plan = {
|
||||
siegesAttribues: charge.reglages.attribuerSieges,
|
||||
tables: charge.tables.map(({ id: t }) => t),
|
||||
capacites: charge.tables.map((table) => capacite(charge, table)),
|
||||
tours: listes.length,
|
||||
participants,
|
||||
placement: listes.map((tour) => ({ sieges: tour, reserve: participants.filter((p) => !tour.flat().includes(p)) })),
|
||||
};
|
||||
charge.propositions.push({ id, graine: id, arret: 40, historique: 1000, produitVersion: V1, ...plan });
|
||||
charge.prochainsIds.proposition = id + 1;
|
||||
if (retenu) {
|
||||
charge.retenu = { proposition: id, ...structuredClone(plan) };
|
||||
charge.evenement.etat = 'retenu';
|
||||
} else if (charge.evenement.etat === 'brouillon') {
|
||||
charge.evenement.etat = 'propose';
|
||||
}
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Gèle une valeur en profondeur : une commande qui la modifierait lèverait.
|
||||
function figer(valeur) {
|
||||
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
|
||||
Object.freeze(valeur);
|
||||
for (const element of Object.values(valeur)) figer(element);
|
||||
}
|
||||
return valeur;
|
||||
}
|
||||
|
||||
// Vrai quand la configuration de la charge se normalise.
|
||||
function seNormalise(charge) {
|
||||
try {
|
||||
normaliser(configurationDepuisCharge(charge));
|
||||
return true;
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurConfiguration) return false;
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue
|
||||
// se relit, et se normalise quand la charge reçue se normalisait.
|
||||
function accepter(nom, charge, args) {
|
||||
const recue = figer(structuredClone(charge));
|
||||
const rendu = appliquerCommande(nom, recue, args);
|
||||
analyser(serialiser(rendu.charge, { revision: 1, produitVersion: 'essai' }));
|
||||
if (seNormalise(recue)) assert.ok(seNormalise(rendu.charge), `${nom} : la charge rendue ne se normalise plus`);
|
||||
return rendu;
|
||||
}
|
||||
|
||||
// Le refus d'une ErreurCommande de ce code ; ses détails contiennent ceux
|
||||
// donnés.
|
||||
function refuse(appel, code, attendus = {}) {
|
||||
let leve = null;
|
||||
try {
|
||||
appel();
|
||||
} catch (erreur) {
|
||||
leve = erreur;
|
||||
}
|
||||
assert.ok(leve instanceof ErreurCommande, `ErreurCommande ${code} attendue, reçu ${leve}`);
|
||||
assert.equal(leve.code, code, leve.message);
|
||||
for (const [cle, valeur] of Object.entries(attendus)) assert.deepEqual(leve.details[cle], valeur, cle);
|
||||
return leve;
|
||||
}
|
||||
|
||||
// Le même refus, rendu par une promesse.
|
||||
async function refuseAsync(promesse, code, attendus = {}) {
|
||||
let leve = null;
|
||||
try {
|
||||
await promesse;
|
||||
} catch (erreur) {
|
||||
leve = erreur;
|
||||
}
|
||||
return refuse(() => {
|
||||
if (leve !== null) throw leve;
|
||||
}, code, attendus);
|
||||
}
|
||||
|
||||
// --- La séance d'épreuve ----------------------------------------------------
|
||||
|
||||
// Une séance sur le système d'épreuve, l'événement de la charge posé par le
|
||||
// dépôt, ouvert et passé en écriture. entrees() compte les entrées du
|
||||
// journal ; ecritures() les écritures du système.
|
||||
async function seanceSur(charge) {
|
||||
const fs = creerFichiersSimules();
|
||||
fs.deposerDossier('documents');
|
||||
await creerDansLeDepot(fs, DOC, charge, {
|
||||
libelle: libelle('ENTREE_CREATION', { nom: NOM }),
|
||||
nomJalon: libelle('JALON_CREATION'),
|
||||
horodatage: '2031-05-17T07:00:00-04:00',
|
||||
produitVersion: V1,
|
||||
});
|
||||
let tirage = 0;
|
||||
const seance = creerSeance({
|
||||
fs,
|
||||
racine: DOC,
|
||||
horloge: { horodatage: () => '2031-05-17T08:00:00-04:00' },
|
||||
identifiant: () => {
|
||||
tirage += 1;
|
||||
return `seance-${tirage}`;
|
||||
},
|
||||
produitVersion: V1,
|
||||
planifier: () => 1,
|
||||
annuler: () => {},
|
||||
});
|
||||
await seance.ouvrir(NOM);
|
||||
await seance.passerEnEcriture();
|
||||
return {
|
||||
seance,
|
||||
entrees: () => lireJournal(fs.contenu('documents', `${NOM}${SUFFIXES.journal}`)).entrees.length,
|
||||
ecritures: () => fs.compteurs.ecritures,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Les épreuves -----------------------------------------------------------
|
||||
|
||||
describe('mobilier : le registre (§ 8.2)', () => {
|
||||
test('COMMANDES_MOBILIER porte les huit commandes du mobilier, réunies dans COMMANDES', () => {
|
||||
const noms = ['creerTable', 'supprimerTable', 'deplacerTable', 'changerPlacesTable', 'realignerTable', 'changerDefaut', 'changerTours', 'modifierReglage'];
|
||||
assert.deepEqual([...COMMANDES_MOBILIER.keys()], noms);
|
||||
for (const nom of noms) assert.equal(COMMANDES.get(nom), COMMANDES_MOBILIER.get(nom), nom);
|
||||
});
|
||||
|
||||
test('un plan bloqué refuse chacune, PLAN_BLOQUE, remède debloquer', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
|
||||
charge.evenement.etat = 'bloque';
|
||||
const gestes = [
|
||||
['creerTable', { forme: 'ronde' }],
|
||||
['supprimerTable', { id: 1 }],
|
||||
['deplacerTable', { id: 1, position: { x: 5, y: 5 } }],
|
||||
['changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' }],
|
||||
['realignerTable', { id: 1 }],
|
||||
['changerDefaut', { sieges: 6, realigner: false }],
|
||||
['changerTours', { tours: 3 }],
|
||||
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
|
||||
];
|
||||
assert.equal(gestes.length, COMMANDES_MOBILIER.size);
|
||||
for (const [nom, args] of gestes) {
|
||||
refuse(() => COMMANDES.get(nom)(charge, args), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => {
|
||||
test("les identifiants ne se réutilisent jamais ; le numéro suit le plus grand, et le libellé nomme le numéro", () => {
|
||||
let charge = chargeDe();
|
||||
for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||
assert.deepEqual(charge.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [2, 2], [3, 3]]);
|
||||
charge = accepter('supprimerTable', charge, { id: 3 }).charge;
|
||||
const cree = accepter('creerTable', charge, { forme: 'carree' });
|
||||
assert.equal(cree.libelle, 'Ajouté la table 3');
|
||||
assert.deepEqual(cree.charge.tables.at(-1), {
|
||||
id: 4,
|
||||
numero: 3,
|
||||
sieges: null,
|
||||
forme: 'carree',
|
||||
position: caseLibre(charge.tables.map(({ position }) => position)),
|
||||
});
|
||||
assert.equal(cree.charge.prochainsIds.table, 5);
|
||||
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } });
|
||||
assert.equal(deplace.libelle, 'Déplacé la table 3');
|
||||
assert.deepEqual(deplace.charge.tables.at(-1).position, { x: 612, y: 0 });
|
||||
assert.ok(Object.is(deplace.charge.tables.at(-1).position.y, 0));
|
||||
// Une position que l'arrondi ne change pas : le geste est sans effet.
|
||||
refuse(() => appliquerCommande('deplacerTable', deplace.charge, { id: 4, position: { x: 611.6, y: 0.2 } }), 'SANS_EFFET');
|
||||
|
||||
// Supprimer une table du milieu : le numéro suit encore le plus grand,
|
||||
// et non le nombre de tables.
|
||||
let milieu = chargeDe();
|
||||
for (let i = 0; i < 3; i += 1) milieu = accepter('creerTable', milieu, { forme: 'ronde' }).charge;
|
||||
milieu = accepter('supprimerTable', milieu, { id: 2 }).charge;
|
||||
const apresMilieu = accepter('creerTable', milieu, { forme: 'ronde' });
|
||||
assert.equal(apresMilieu.libelle, 'Ajouté la table 4');
|
||||
assert.deepEqual(apresMilieu.charge.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [3, 3], [4, 4]]);
|
||||
});
|
||||
|
||||
test('une forme inconnue, une table inconnue ou une position non finie sont des fautes du code', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }] });
|
||||
assert.throws(() => COMMANDES.get('creerTable')(charge, { forme: 'ovale' }), TypeError);
|
||||
assert.throws(() => COMMANDES.get('supprimerTable')(charge, { id: 7 }), TypeError);
|
||||
assert.throws(() => COMMANDES.get('deplacerTable')(charge, { id: 1, position: { x: Number.NaN, y: 0 } }), TypeError);
|
||||
});
|
||||
|
||||
test('une table créée suit le défaut : sieges null, 8 places sous un défaut de 8, 10 sous un défaut de 10', () => {
|
||||
for (const defaut of [8, 10]) {
|
||||
const { charge } = accepter('creerTable', chargeDe({ defaut }), { forme: 'ronde' });
|
||||
assert.equal(charge.tables[0].sieges, null);
|
||||
assert.equal(capacite(charge, charge.tables[0]), defaut);
|
||||
}
|
||||
const huit = accepter('creerTable', chargeDe(), { forme: 'ronde' }).charge;
|
||||
const dix = accepter('changerDefaut', huit, { sieges: 10, realigner: false }).charge;
|
||||
assert.equal(capacite(dix, dix.tables[0]), 10);
|
||||
});
|
||||
|
||||
test('une table créée après la retenue entre au retenu, vide à chaque tour ; les propositions dérivent TABLE_AJOUTEE', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 });
|
||||
const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]];
|
||||
placer(charge, listes, { id: 1 });
|
||||
placer(charge, listes, { id: 2, retenu: true });
|
||||
const { charge: apres, libelle: texte } = accepter('creerTable', charge, { forme: 'ronde' });
|
||||
assert.equal(texte, 'Ajouté la table 3');
|
||||
assert.deepEqual(apres.retenu.tables, [1, 2, 3]);
|
||||
assert.deepEqual(apres.retenu.capacites, [8, 8, 8]);
|
||||
for (const { sieges } of apres.retenu.placement) assert.deepEqual(sieges.at(-1), []);
|
||||
assert.deepEqual(fautes(apres.retenu), []);
|
||||
assert.deepEqual(derive(apres.retenu, apres), []);
|
||||
assert.deepEqual(apres.propositions, charge.propositions);
|
||||
assert.deepEqual(examiner(apres).derives, [
|
||||
{ id: 1, raisons: [{ code: 'TABLE_AJOUTEE', table: 3 }] },
|
||||
{ id: 2, raisons: [{ code: 'TABLE_AJOUTEE', table: 3 }] },
|
||||
]);
|
||||
// Un retenu fautif ne se réécrit pas.
|
||||
const fautif = structuredClone(charge);
|
||||
fautif.retenu.capacites = [8];
|
||||
const sansRetouche = accepter('creerTable', fautif, { forme: 'ronde' }).charge;
|
||||
assert.deepEqual(sansRetouche.retenu, fautif.retenu);
|
||||
});
|
||||
|
||||
test("identifiant contre numéro : supprimer la table 2 laisse la réservation et les listes de la table 3 du retenu", () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 6, reservations: [resa(1, 3, null)] });
|
||||
placer(charge, [[[2, 3], [4, 5], [1, 6]], [[4, 6], [2, 3], [1, 5]]], { id: 1, retenu: true });
|
||||
const { charge: apres, libelle: texte } = accepter('supprimerTable', charge, { id: 2 });
|
||||
assert.equal(texte, 'Supprimé la table 2');
|
||||
assert.deepEqual(apres.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [3, 3]]);
|
||||
assert.deepEqual(apres.reservations, [resa(1, 3, null)]);
|
||||
assert.deepEqual(apres.retenu, charge.retenu);
|
||||
assert.ok(examiner(apres).retenu.raisons.some(({ code, table }) => code === 'TABLE_SUPPRIMEE' && table === 2));
|
||||
});
|
||||
|
||||
test("supprimer une table garnie retire, dans la même entrée, ses réservations et ses titres", async () => {
|
||||
const charge = chargeDe({
|
||||
tables: [{ id: 1 }, { id: 2 }],
|
||||
n: 4,
|
||||
reservations: [resa(2, 2, 2), resa(1, 1, null), resa(3, 2, null, 1)],
|
||||
titres: [{ table: 2, siege: 2, libelle: 'animateur' }, { table: 1, siege: 1, libelle: 'accueil' }],
|
||||
});
|
||||
const { seance, entrees } = await seanceSur(charge);
|
||||
const avant = entrees();
|
||||
const rendu = await seance.executer('supprimerTable', { id: 2 });
|
||||
assert.equal(rendu.libelle, 'Supprimé la table 2 ; retirés : 2 réservations et 1 titre');
|
||||
assert.equal(entrees() - avant, 1);
|
||||
assert.deepEqual(seance.charge.reservations, [resa(1, 1, null)]);
|
||||
assert.deepEqual(seance.charge.titres, [{ table: 1, siege: 1, libelle: 'accueil' }]);
|
||||
// Au singulier : une réservation, un titre ; une table nue ne retire rien.
|
||||
const seule = await seance.executer('supprimerTable', { id: 1 });
|
||||
assert.equal(seule.libelle, 'Supprimé la table 1 ; retirés : 1 réservation et 1 titre');
|
||||
const vide = accepter('supprimerTable', chargeDe({ tables: [{ id: 4 }] }), { id: 4 });
|
||||
assert.equal(vide.libelle, 'Supprimé la table 4');
|
||||
});
|
||||
|
||||
test('le manque : supprimer une table de 8 sous Σ c = N = 16 avertit PLACES_MANQUANTES {8} ; ramener une table à 6, {2}', async () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 16 });
|
||||
const un = await seanceSur(charge);
|
||||
const rendu = await un.seance.executer('supprimerTable', { id: 2 });
|
||||
assert.equal(nombrePlacesManquantes(normaliser(configurationDepuisCharge(un.seance.charge))), 8);
|
||||
assert.deepEqual(rendu.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 8 } }]);
|
||||
const deux = await seanceSur(charge);
|
||||
const reduit = await deux.seance.executer('changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' });
|
||||
assert.deepEqual(reduit.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 2 } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobilier : les places d'une table, par le champ et par la poignée (§ 6.2, § 7.1)", () => {
|
||||
test('les saisies : « 1 » et « 0 » refusent MOINS_DE_DEUX ; un texte non entier, NON_ENTIER ; « 2 » passe', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
|
||||
for (const sieges of ['1', '0']) {
|
||||
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges, origine: 'champ' }), 'MOINS_DE_DEUX', { remede: null });
|
||||
}
|
||||
const textes = ['-3', '2.5', '2,5', '8abc', 'huit', ''];
|
||||
assert.equal(textes.length, 6);
|
||||
for (const sieges of textes) {
|
||||
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges, origine: 'champ' }), 'NON_ENTIER', { saisie: sieges, remede: null });
|
||||
}
|
||||
const deux = accepter('changerPlacesTable', charge, { id: 1, sieges: '2', origine: 'champ' });
|
||||
assert.equal(deux.charge.tables[0].sieges, 2);
|
||||
assert.equal(deux.libelle, 'Table 1 : 2 places');
|
||||
});
|
||||
|
||||
test("une surcharge au défaut est une surcharge ; réaligner la retire, et le libellé nomme le défaut", () => {
|
||||
const charge = chargeDe({ tables: [{ id: 5, numero: 2 }], n: 2 });
|
||||
const surchargee = accepter('changerPlacesTable', charge, { id: 5, sieges: 8, origine: 'champ' });
|
||||
assert.equal(surchargee.charge.tables[0].sieges, 8);
|
||||
assert.equal(surchargee.libelle, 'Table 2 : 8 places');
|
||||
const realignee = accepter('realignerTable', surchargee.charge, { id: 5 });
|
||||
assert.equal(realignee.charge.tables[0].sieges, null);
|
||||
assert.equal(realignee.libelle, 'Table 2 : suit le défaut (8 places)');
|
||||
refuse(() => appliquerCommande('realignerTable', realignee.charge, { id: 5 }), 'SANS_EFFET');
|
||||
});
|
||||
|
||||
test("le champ dans le retenu, sièges attribués : les occupants des chaises 7 et 8 passent en réserve, la capacité du retenu suit", () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18, attribuerSieges: true });
|
||||
placer(
|
||||
charge,
|
||||
[
|
||||
[suite(1, 6), suite(7, 12), suite(13, 18)],
|
||||
[suite(1, 5), suite(6, 10), [18, 11, 12, 13, 14, 15, 16, 17]],
|
||||
],
|
||||
{ id: 1, retenu: true },
|
||||
);
|
||||
const chaises = etatPlaces(charge, { tour: 2 }).tables[2].chaises;
|
||||
const personnes = [chaises[6].occupant, chaises[7].occupant];
|
||||
assert.deepEqual(personnes, [16, 17]);
|
||||
const { charge: apres, avertissements, libelle: texte } = accepter('changerPlacesTable', charge, { id: 3, sieges: 6, origine: 'champ' });
|
||||
assert.equal(texte, 'Table 3 : 6 places');
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes } }]);
|
||||
const etage = apres.retenu.placement[1];
|
||||
assert.deepEqual(etage.sieges[2], [18, 11, 12, 13, 14, 15]);
|
||||
assert.deepEqual(etage.reserve, [16, 17]);
|
||||
assert.deepEqual([...etage.sieges.flat(), ...etage.reserve].sort((a, b) => a - b), suite(1, 18));
|
||||
assert.deepEqual(apres.retenu.placement[0], charge.retenu.placement[0]);
|
||||
assert.deepEqual(apres.retenu.capacites, [8, 8, 6]);
|
||||
assert.deepEqual(fautes(apres.retenu), []);
|
||||
assert.deepEqual(derive(apres.retenu, apres), []);
|
||||
assert.deepEqual(apres.propositions, charge.propositions);
|
||||
assert.deepEqual(derive(apres.propositions[0], apres), [{ code: 'CAPACITE_CHANGEE', table: 3, avant: 8, maintenant: 6 }]);
|
||||
});
|
||||
|
||||
test("le champ dans le retenu, sièges non attribués : la personne réservée au siège 2 reste, les deux suivantes partent", () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18, reservations: [resa(18, 3, 2)] });
|
||||
placer(
|
||||
charge,
|
||||
[
|
||||
[suite(1, 6), suite(7, 12), suite(13, 18)],
|
||||
[suite(1, 5), suite(6, 10), suite(11, 18)],
|
||||
],
|
||||
{ id: 1, retenu: true },
|
||||
);
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 3, sieges: 6, origine: 'champ' });
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [16, 17] } }]);
|
||||
assert.deepEqual(apres.retenu.placement[1].sieges[2], [11, 12, 13, 14, 15, 18]);
|
||||
assert.deepEqual(apres.retenu.placement[1].reserve, [16, 17]);
|
||||
assert.deepEqual(derive(apres.retenu, apres), []);
|
||||
// La personne réservée garde sa place au plan, au siège 2.
|
||||
assert.equal(etatPlaces(apres, { tour: 2 }).tables[2].chaises[1].occupant, 18);
|
||||
});
|
||||
|
||||
test('sièges attribués : une liste réduite ne finit jamais par une chaise vide ; fautes(retenu) reste vide', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, attribuerSieges: true });
|
||||
placer(charge, [[suite(1, 6), [7, 8, 9, 10, 11, null, null, 12]]], { id: 1, retenu: true });
|
||||
for (const sieges of [6, 7]) {
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 2, sieges, origine: 'champ' });
|
||||
assert.deepEqual(apres.retenu.placement[0].sieges[1], [7, 8, 9, 10, 11]);
|
||||
assert.deepEqual(apres.retenu.placement[0].reserve, [12]);
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 1, personnes: [12] } }]);
|
||||
assert.deepEqual(fautes(apres.retenu), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('la poignée : 8 → 9 accepté, la capacité du retenu suit ; sous l\'occupation, SOUS_OCCUPES et rien ne change', () => {
|
||||
const cinq = suite(1, 5);
|
||||
const charge = chargeDe({ tables: [{ id: 1 }], n: 7, tours: 4 });
|
||||
placer(charge, [cinq, cinq, suite(1, 7), cinq].map((liste) => [liste]), { id: 1, retenu: true });
|
||||
const neuf = accepter('changerPlacesTable', charge, { id: 1, sieges: 9, origine: 'poignee' });
|
||||
assert.deepEqual(neuf.charge.retenu.capacites, [9]);
|
||||
assert.deepEqual(neuf.charge.retenu.placement, charge.retenu.placement);
|
||||
assert.deepEqual(neuf.avertissements, []);
|
||||
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: 6, origine: 'poignee' }), 'SOUS_OCCUPES', {
|
||||
table: 1,
|
||||
n: 7,
|
||||
tour: 3,
|
||||
remede: afficherTour(3),
|
||||
});
|
||||
});
|
||||
|
||||
test('réaligner une table surchargée à 10 qui en assied dix, sous un défaut de 8 : deux surnuméraires', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 16, tours: 1 });
|
||||
placer(charge, [[suite(1, 10), suite(11, 16)]], { id: 1, retenu: true });
|
||||
const { charge: apres, avertissements, libelle: texte } = accepter('realignerTable', charge, { id: 1 });
|
||||
assert.equal(texte, 'Table 1 : suit le défaut (8 places)');
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [9, 10] } }]);
|
||||
assert.deepEqual(apres.retenu.placement[0].reserve, [9, 10]);
|
||||
assert.deepEqual(apres.retenu.capacites, [8, 8]);
|
||||
assert.deepEqual(derive(apres.retenu, apres), []);
|
||||
});
|
||||
|
||||
test("une hausse n'assied personne que le plan ne montrait pas : un retenu déclaré à 10 sur une table de 8", () => {
|
||||
// Avec la personne 9 réservée à la table 1 sans siège : sièges attribués,
|
||||
// son rang 9 ne se montre pas et la hausse ne l'assied pas ; sans
|
||||
// attribution, l'affichage l'élit déjà, et la personne 8 n'est pas montrée.
|
||||
// La dérive du retenu masquait la réservation non tenue de la personne 9 ;
|
||||
// la capacité rejointe, invariantViole la nomme.
|
||||
const nonTenue = { code: 'INVARIANT_VIOLE', details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 9, table: 1, tour: 1 }] } };
|
||||
const cas = [
|
||||
{ attribuerSieges: true, reservations: [], liste: suite(1, 8), reserve: [9, 10], invariant: null },
|
||||
{ attribuerSieges: false, reservations: [], liste: suite(1, 8), reserve: [9, 10], invariant: null },
|
||||
{ attribuerSieges: true, reservations: [resa(9, 1, null)], liste: suite(1, 8), reserve: [9, 10], invariant: nonTenue },
|
||||
{ attribuerSieges: false, reservations: [resa(9, 1, null)], liste: [1, 2, 3, 4, 5, 6, 7, 9], reserve: [8, 10], invariant: null },
|
||||
];
|
||||
for (const { attribuerSieges, reservations, liste, reserve, invariant } of cas) {
|
||||
for (const origine of ['champ', 'poignee']) {
|
||||
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations });
|
||||
placer(charge, [[suite(1, 10), [11, 12]]], { id: 1, retenu: true });
|
||||
charge.tables[0].sieges = null;
|
||||
const montrees = etatPlaces(charge, { tour: 1 }).tables[0].chaises.map(({ occupant }) => occupant);
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 10, origine });
|
||||
const nom = `attribuerSieges ${attribuerSieges}, ${reservations.length} réservation, ${origine}`;
|
||||
assert.deepEqual(montrees, liste, nom);
|
||||
assert.deepEqual(avertissements, [], nom);
|
||||
assert.deepEqual(apres.retenu.placement[0].sieges[0], liste, nom);
|
||||
assert.deepEqual(apres.retenu.placement[0].reserve, reserve, nom);
|
||||
assert.deepEqual(apres.retenu.capacites, [10, 8], nom);
|
||||
assert.deepEqual(derive(apres.retenu, apres), [], nom);
|
||||
assert.equal(invariantViole(charge), null, nom);
|
||||
assert.deepEqual(invariantViole(apres), invariant, nom);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('un retenu de plus de tours que la soirée se réduit à chacun de ses tours, sans faute', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, attribuerSieges: true });
|
||||
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
|
||||
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
|
||||
// Le tour 3 n'est plus de la soirée, mais le retenu le porte encore : ses
|
||||
// surnuméraires se nomment comme ceux d'un tour montré.
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [11, 12] } }]);
|
||||
assert.deepEqual(apres.retenu.placement[2].sieges[1], suite(5, 10));
|
||||
assert.deepEqual(apres.retenu.placement[2].reserve, [11, 12]);
|
||||
assert.deepEqual(fautes(apres.retenu), []);
|
||||
});
|
||||
|
||||
test('sans attribution, un tour du retenu que la soirée n\'a plus élit aussi la personne que la table réserve', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, reservations: [resa(12, 2, null)] });
|
||||
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
|
||||
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [10, 11] } }]);
|
||||
assert.deepEqual(apres.retenu.placement[2].sieges[1], [5, 6, 7, 8, 9, 12]);
|
||||
assert.deepEqual(apres.retenu.placement[2].reserve, [10, 11]);
|
||||
assert.deepEqual(fautes(apres.retenu), []);
|
||||
});
|
||||
|
||||
test("une réduction acceptée garde chaque réservation que le retenu tenait : invariantViole reste null", () => {
|
||||
// La personne 8, réservée à la table 1 sans siège, est assise à la chaise 8.
|
||||
const gestes = [
|
||||
['changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' }],
|
||||
['changerDefaut', { sieges: 6, realigner: false }],
|
||||
];
|
||||
let acceptees = 0;
|
||||
for (const attribuerSieges of [true, false]) {
|
||||
for (const [nom, args] of gestes) {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations: [resa(8, 1, null)] });
|
||||
placer(charge, [[suite(1, 8), suite(9, 12)]], { id: 1, retenu: true });
|
||||
assert.equal(invariantViole(charge), null);
|
||||
const { charge: apres, avertissements } = accepter(nom, charge, args);
|
||||
const cas = `${nom}, attribuerSieges ${attribuerSieges}`;
|
||||
assert.equal(invariantViole(apres), null, cas);
|
||||
// Un non-réservé cède sa chaise : la chaise 6, la plus haute, sous des
|
||||
// sièges attribués ; sinon l'affichage élit d'abord la réservée.
|
||||
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 8], cas);
|
||||
assert.deepEqual(apres.retenu.placement[0].reserve, [6, 7], cas);
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [6, 7] } }], cas);
|
||||
assert.deepEqual(derive(apres.retenu, apres), [], cas);
|
||||
acceptees += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(acceptees, 4);
|
||||
|
||||
// Sièges attribués, une chaise vide dans la capacité reçoit la réservée
|
||||
// avant qu'un non-réservé ne cède la sienne.
|
||||
const vide = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1, attribuerSieges: true, reservations: [resa(8, 1, null)] });
|
||||
placer(vide, [[[1, 2, 3, null, 5, 6, 7, 8], suite(9, 12)]], { id: 1, retenu: true });
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', vide, { id: 1, sieges: 6, origine: 'champ' });
|
||||
assert.equal(invariantViole(apres), null);
|
||||
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 2, 3, 8, 5, 6]);
|
||||
assert.deepEqual(apres.retenu.placement[0].reserve, [4, 7]);
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [7] } }]);
|
||||
|
||||
// Réaligner une table surchargée à 10, la personne 10 réservée sans siège.
|
||||
const dix = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 14, tours: 1, attribuerSieges: true, reservations: [resa(10, 1, null)] });
|
||||
placer(dix, [[suite(1, 10), suite(11, 14)]], { id: 1, retenu: true });
|
||||
const realignee = accepter('realignerTable', dix, { id: 1 });
|
||||
assert.equal(invariantViole(realignee.charge), null);
|
||||
assert.deepEqual(realignee.charge.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 6, 7, 10]);
|
||||
assert.deepEqual(realignee.avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [8, 9] } }]);
|
||||
});
|
||||
|
||||
test('sièges attribués, le retour des réservés : siège voulu, chaise libre ou d\'un absent, non-réservé du plus haut rang, ordre des rangs, tour couvert', () => {
|
||||
// La table 1, de 8, réduite à 6 par le champ ; les exclus le deviennent
|
||||
// après la retenue. Par tour : la liste qui reste, la réserve, et les
|
||||
// personnes de SURNUMERAIRES.
|
||||
const cas = [
|
||||
{
|
||||
nom: "une chaise tenue par un absent est libre : la personne 8 la prend, l'absent 2 passe en réserve",
|
||||
reservations: [resa(8, 1, null)],
|
||||
exclus: [2],
|
||||
listes: [[suite(1, 8), suite(9, 12)]],
|
||||
attendus: [[[1, 8, 3, 4, 5, 6], [2, 7], [7]]],
|
||||
},
|
||||
{
|
||||
nom: 'le siège réservé, libre, passe avant la première chaise libre',
|
||||
reservations: [resa(9, 1, 3), resa(9, 1, 4, 1)],
|
||||
listes: [[[1, null, null, null, 5, 6, 7, 9], [2, 3, 4, 8, 10, 11, 12]]],
|
||||
attendus: [[[1, null, 9, null, 5, 6], [7], [7]]],
|
||||
},
|
||||
{
|
||||
nom: "le siège réservé est le premier siège non nul des réservations de la personne, dans l'ordre canonique",
|
||||
reservations: [resa(9, 1, null), resa(9, 1, 4, 1)],
|
||||
listes: [[[1, null, null, null, 5, 6, 7, 9], [2, 3, 4, 8, 10, 11, 12]]],
|
||||
attendus: [[[1, null, null, 9, 5, 6], [7], [7]]],
|
||||
},
|
||||
{
|
||||
nom: "un siège réservé tenu par une personne présente n'est pas libre : le non-réservé du plus haut rang cède le sien",
|
||||
reservations: [resa(9, 1, 3)],
|
||||
listes: [[[1, 2, 3, 4, 5, 6, 7, 9], [8, 10, 11, 12]]],
|
||||
attendus: [[[1, 2, 3, 4, 5, 9], [6, 7], [6, 7]]],
|
||||
},
|
||||
{
|
||||
nom: 'une personne réservée ne cède pas sa chaise : la personne 5 part, la 6 reste',
|
||||
reservations: [resa(6, 1, null), resa(8, 1, null)],
|
||||
listes: [[suite(1, 8), suite(9, 12)]],
|
||||
attendus: [[[1, 2, 3, 4, 8, 6], [5, 7], [5, 7]]],
|
||||
},
|
||||
{
|
||||
nom: "dans l'ordre des rangs : la personne 7 prend la chaise vide, la 8 celle du plus haut rang",
|
||||
reservations: [resa(7, 1, null), resa(8, 1, null)],
|
||||
listes: [[[1, null, 3, 4, 5, 6, 7, 8], [2, 9, 10, 11, 12]]],
|
||||
attendus: [[[1, 7, 3, 4, 5, 8], [6], [6]]],
|
||||
},
|
||||
{
|
||||
nom: 'une réservation « tour » ramène sa personne au tour 2 seulement',
|
||||
reservations: [resa(8, 1, null, 2)],
|
||||
listes: [
|
||||
[suite(1, 8), suite(9, 12)],
|
||||
[suite(1, 8), suite(9, 12)],
|
||||
],
|
||||
attendus: [
|
||||
[suite(1, 6), [7, 8], [7, 8]],
|
||||
[[1, 2, 3, 4, 5, 8], [6, 7], [6, 7]],
|
||||
],
|
||||
},
|
||||
{
|
||||
nom: 'la réservation suspendue de la personne 8, exclue, ne la ramène pas',
|
||||
reservations: [resa(8, 1, null)],
|
||||
exclus: [8],
|
||||
listes: [[suite(1, 8), suite(9, 12)]],
|
||||
attendus: [[suite(1, 6), [7, 8], [7]]],
|
||||
},
|
||||
];
|
||||
for (const { nom, reservations, exclus = [], listes, attendus } of cas) {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: listes.length, attribuerSieges: true, reservations });
|
||||
placer(charge, listes, { id: 1, retenu: true });
|
||||
for (const id of exclus) charge.participants[id - 1].exclu = true;
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
|
||||
attendus.forEach(([liste, reserve], rang) => {
|
||||
assert.deepEqual(apres.retenu.placement[rang].sieges[0], liste, `${nom}, tour ${rang + 1}`);
|
||||
assert.deepEqual(apres.retenu.placement[rang].reserve, reserve, `${nom}, tour ${rang + 1}`);
|
||||
});
|
||||
const surnumeraires = attendus.map(([, , personnes], rang) => ({ code: 'SURNUMERAIRES', details: { table: 1, tour: rang + 1, personnes } }));
|
||||
assert.deepEqual(avertissements, surnumeraires, nom);
|
||||
assert.equal(invariantViole(apres), null, nom);
|
||||
// La réduction n'ajoute aucune raison de dérive ; un exclu garde la sienne.
|
||||
assert.deepEqual(derive(apres.retenu, apres), derive(charge.retenu, charge), nom);
|
||||
assert.deepEqual(fautes(apres.retenu), [], nom);
|
||||
}
|
||||
});
|
||||
|
||||
test("sièges attribués, un tour du retenu que la soirée n'a plus ramène la personne qu'une réservation « tous » tient", () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, attribuerSieges: true, reservations: [resa(12, 2, null)] });
|
||||
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
|
||||
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [10, 11] } }]);
|
||||
assert.deepEqual(apres.retenu.placement[2].sieges[1], [5, 6, 7, 8, 9, 12]);
|
||||
assert.deepEqual(apres.retenu.placement[2].reserve, [10, 11]);
|
||||
assert.deepEqual(fautes(apres.retenu), []);
|
||||
assert.equal(invariantViole(apres), null);
|
||||
});
|
||||
|
||||
test('une baisse ne ramène pas la personne réservée d\'un rang au-delà de la capacité courante, que le plan ne montrait pas', () => {
|
||||
// Le retenu déclare la table 1 à 10, qui n'en a plus que 8 : sièges
|
||||
// attribués, la personne 9, réservée sans siège, est au rang 9.
|
||||
// Sièges attribués, la réservation que le plan montrait non tenue le reste,
|
||||
// et invariantViole la nomme une fois la dérive levée ; sans attribution,
|
||||
// l'affichage élisait la personne 9, qui reste.
|
||||
const nonTenue = { code: 'INVARIANT_VIOLE', details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 9, table: 1, tour: 1 }] } };
|
||||
const cas = [
|
||||
{ attribuerSieges: true, liste: suite(1, 6), reserve: [7, 8, 9, 10], personnes: [7, 8], invariant: nonTenue },
|
||||
{ attribuerSieges: false, liste: [1, 2, 3, 4, 5, 9], reserve: [6, 7, 8, 10], personnes: [6, 7], invariant: null },
|
||||
];
|
||||
for (const { attribuerSieges, liste, reserve, personnes, invariant } of cas) {
|
||||
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations: [resa(9, 1, null)] });
|
||||
placer(charge, [[suite(1, 10), [11, 12]]], { id: 1, retenu: true });
|
||||
charge.tables[0].sieges = null;
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
|
||||
const nom = `attribuerSieges ${attribuerSieges}`;
|
||||
assert.deepEqual(apres.retenu.placement[0].sieges[0], liste, nom);
|
||||
assert.deepEqual(apres.retenu.placement[0].reserve, reserve, nom);
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes } }], nom);
|
||||
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve.includes(9), attribuerSieges, nom);
|
||||
assert.deepEqual(invariantViole(apres), invariant, nom);
|
||||
}
|
||||
});
|
||||
|
||||
test('sans attribution, une liste qui dépasse encore perd ses absents, le plus petit identifiant d\'abord', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1 });
|
||||
placer(charge, [[suite(1, 8), suite(9, 12)]], { id: 1, retenu: true });
|
||||
for (const id of [2, 3]) charge.participants[id - 1].exclu = true;
|
||||
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
|
||||
// Six présents s'assoient ; les deux absents partent, le plus petit d'abord, jusqu'à six.
|
||||
assert.deepEqual(avertissements, []);
|
||||
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 4, 5, 6, 7, 8]);
|
||||
assert.deepEqual(apres.retenu.placement[0].reserve, [2, 3]);
|
||||
const sept = accepter('changerPlacesTable', charge, { id: 1, sieges: 7, origine: 'champ' }).charge;
|
||||
assert.deepEqual(sept.retenu.placement[0].sieges[0], [1, 3, 4, 5, 6, 7, 8]);
|
||||
assert.deepEqual(sept.retenu.placement[0].reserve, [2]);
|
||||
});
|
||||
|
||||
test('un compteur de tables saturé refuse COMPTEUR_SATURE ; une origine ou un réalignement de forme fausse sont des fautes du code', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
|
||||
charge.prochainsIds.table = 2 ** 31 - 1;
|
||||
refuse(() => COMMANDES.get('creerTable')(charge, { forme: 'ronde' }), 'COMPTEUR_SATURE', { compteur: 'table', remede: null });
|
||||
assert.throws(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: 6, origine: 'clavier' }), TypeError);
|
||||
assert.throws(() => COMMANDES.get('changerDefaut')(charge, { sieges: 6 }), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobilier : changer le défaut (§ 6.1, § 6.2)', () => {
|
||||
function treizeTables(options = {}) {
|
||||
const surcharges = new Map([[3, 7], [6, 7], [9, 6], [12, 12]]);
|
||||
const tables = Array.from({ length: 13 }, (_, rang) => ({ id: rang + 1, sieges: surcharges.get(rang + 1) ?? null }));
|
||||
return chargeDe({ tables, n: 120, exclus: [40, 80], ...options });
|
||||
}
|
||||
const capacites = (charge) => charge.tables.map((table) => capacite(charge, table));
|
||||
|
||||
test('appliquer : les quatre surchargées gardent 7, 7, 6 et 12, les neuf autres valent 10, une entrée ; réaligner : les treize suivent 10', async () => {
|
||||
const { seance, entrees } = await seanceSur(treizeTables());
|
||||
const avant = entrees();
|
||||
const rendu = await seance.executer('changerDefaut', { sieges: 10, realigner: false });
|
||||
assert.equal(rendu.libelle, 'Sièges par défaut : 10');
|
||||
assert.equal(entrees() - avant, 1);
|
||||
assert.deepEqual(capacites(seance.charge), [10, 10, 7, 10, 10, 7, 10, 10, 6, 10, 10, 12, 10]);
|
||||
assert.deepEqual(seance.charge.tables.filter(({ sieges }) => sieges !== null).map(({ id }) => id), [3, 6, 9, 12]);
|
||||
const realigne = await seance.executer('changerDefaut', { sieges: 10, realigner: true });
|
||||
assert.equal(realigne.libelle, 'Sièges par défaut : 10 ; 4 tables réalignées');
|
||||
assert.equal(entrees() - avant, 2);
|
||||
assert.ok(seance.charge.tables.every(({ sieges }) => sieges === null));
|
||||
assert.deepEqual(capacites(seance.charge), Array(13).fill(10));
|
||||
});
|
||||
|
||||
test('réaligner refusé en bloc : le titre du siège 12 refuse, table et remède compris, et rien ne s\'écrit ; sans réaligner, accepté', async () => {
|
||||
const charge = treizeTables({ attribuerSieges: true, titres: [{ table: 12, siege: 12, libelle: 'animateur' }] });
|
||||
const { seance, ecritures } = await seanceSur(charge);
|
||||
const avant = ecritures();
|
||||
const leve = await refuseAsync(seance.executer('changerDefaut', { sieges: 10, realigner: true }), 'SIEGES_RETIRES', {
|
||||
table: 12,
|
||||
sieges: [{ siege: 12, cause: 'SIEGE_TITRE', libelle: 'animateur' }],
|
||||
remede: { commande: 'retirerTitre', arguments: { table: 12, siege: 12 } },
|
||||
});
|
||||
assert.deepEqual(leve.details.autres, []);
|
||||
assert.equal(ecritures(), avant);
|
||||
assert.deepEqual(capacites(seance.charge), capacites(charge));
|
||||
await seance.executer('changerDefaut', { sieges: 10, realigner: false });
|
||||
assert.equal(seance.charge.tables[11].sieges, 12);
|
||||
});
|
||||
|
||||
test('baisse refusée en bloc : la table 2 porte 7 réservations ; le défaut 6 refuse, nommant la table 2, et les trois tables restent à 8', async () => {
|
||||
const reservations = suite(1, 7).map((id) => resa(id, 2, null));
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 20, reservations });
|
||||
const { seance, ecritures } = await seanceSur(charge);
|
||||
const avant = ecritures();
|
||||
await refuseAsync(seance.executer('changerDefaut', { sieges: 6, realigner: false }), 'SOUS_RESERVATIONS', {
|
||||
table: 2,
|
||||
n: 7,
|
||||
tour: 1,
|
||||
remede: afficherTour(1),
|
||||
});
|
||||
assert.equal(ecritures(), avant);
|
||||
assert.equal(seance.charge.evenement.siegesParDefaut, 8);
|
||||
assert.deepEqual(capacites(seance.charge), [8, 8, 8]);
|
||||
refuse(() => COMMANDES.get('changerDefaut')(charge, { sieges: '1', realigner: false }), 'MOINS_DE_DEUX', { remede: null });
|
||||
refuse(() => COMMANDES.get('changerDefaut')(charge, { sieges: 'six', realigner: false }), 'NON_ENTIER');
|
||||
});
|
||||
|
||||
test('une baisse sous un retenu occupé : accepté, SURNUMERAIRES, et ces deux personnes dans la réserve du tour 2', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
|
||||
placer(
|
||||
charge,
|
||||
[
|
||||
[suite(1, 6), suite(7, 12), suite(13, 18)],
|
||||
[suite(1, 5), suite(6, 10), suite(11, 18)],
|
||||
],
|
||||
{ id: 1, retenu: true },
|
||||
);
|
||||
const { charge: apres, avertissements } = accepter('changerDefaut', charge, { sieges: 6, realigner: false });
|
||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [17, 18] } }]);
|
||||
assert.deepEqual(apres.retenu.placement[1].reserve, [17, 18]);
|
||||
assert.deepEqual(apres.retenu.capacites, [6, 6, 6]);
|
||||
assert.deepEqual(derive(apres.retenu, apres), []);
|
||||
});
|
||||
|
||||
test('un défaut au-delà du seuil de lisibilité avertit une fois', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4 });
|
||||
const { avertissements } = accepter('changerDefaut', charge, { sieges: 40, realigner: false });
|
||||
assert.equal(avertissements.length, 1);
|
||||
assert.equal(avertissements[0].code, 'AU_DELA_LISIBLE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobilier : les tours (§ 2.1 étape 5, § 4.3)', () => {
|
||||
test('R = 4 → 3 → 4 : les réservations reviennent telles quelles, et la portée « tous » ancre encore au tour 4', () => {
|
||||
const debut = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4, tours: 4, reservations: [resa(1, 2, null)] });
|
||||
const trois = accepter('changerTours', debut, { tours: 3 });
|
||||
assert.equal(trois.libelle, 'Nombre de tours : 3');
|
||||
const quatre = accepter('changerTours', trois.charge, { tours: '4' }).charge;
|
||||
assert.deepEqual(quatre.reservations, debut.reservations);
|
||||
const instance = normaliser(configurationDepuisCharge(quatre));
|
||||
const p = instance.indexDe.get(1);
|
||||
assert.equal(instance.statut[p], STATUT.ANCRE);
|
||||
assert.equal(instance.fixe[p * instance.R + 3], instance.indexTableDe.get(2));
|
||||
});
|
||||
|
||||
test("retirer le tour 4 détruit ses réservations « tour », garde les autres, et laisse propositions et retenu en dérive", () => {
|
||||
const reservations = [resa(1, 1, null, 4), resa(2, 2, null, 4), resa(3, 1, null, 2), resa(4, 2, null)];
|
||||
const charge = chargeDe({ tables: [{ id: 1, sieges: 4 }, { id: 2, sieges: 4 }], n: 8, tours: 4, reservations });
|
||||
const listes = [[[1, 2, 3, 5], [4, 6, 7, 8]], [[1, 3, 5, 6], [2, 4, 7, 8]], [[1, 5, 6, 7], [2, 3, 4, 8]], [[1, 3, 5, 7], [2, 4, 6, 8]]];
|
||||
placer(charge, listes, { id: 1 });
|
||||
placer(charge, listes, { id: 2, retenu: true });
|
||||
const { charge: apres, libelle: texte } = accepter('changerTours', charge, { tours: 3 });
|
||||
assert.equal(texte, 'Nombre de tours : 3 ; 2 réservations retirées');
|
||||
assert.deepEqual(apres.reservations, [resa(3, 1, null, 2), resa(4, 2, null)]);
|
||||
analyser(serialiser(apres, { revision: 2, produitVersion: 'essai' }));
|
||||
assert.deepEqual(apres.propositions, charge.propositions);
|
||||
assert.deepEqual(apres.retenu, charge.retenu);
|
||||
assert.deepEqual(derive(apres.retenu, apres), [{ code: 'TOURS_CHANGES', avant: 4, maintenant: 3 }]);
|
||||
});
|
||||
|
||||
test('3 → 4 : trois réservations « tour » à une table rendent B partiellement fixé ; propositions et retenu gardés, TOURS_CHANGES', () => {
|
||||
const b = 5;
|
||||
const reservations = [1, 2, 3].map((tour) => resa(b, 1, null, tour));
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 8, tours: 3, reservations });
|
||||
const listes = [[[1, 2, 3, 5], [4, 6, 7, 8]], [[4, 5, 6, 7], [1, 2, 3, 8]], [[1, 5, 7, 8], [2, 3, 4, 6]]];
|
||||
placer(charge, listes, { id: 1, retenu: true });
|
||||
const avant = normaliser(configurationDepuisCharge(charge));
|
||||
assert.equal(avant.statut[avant.indexDe.get(b)], STATUT.ANCRE);
|
||||
const { charge: apres } = accepter('changerTours', charge, { tours: 4 });
|
||||
const instance = normaliser(configurationDepuisCharge(apres));
|
||||
assert.equal(instance.statut[instance.indexDe.get(b)], STATUT.PARTIELLEMENT_FIXE);
|
||||
assert.deepEqual(apres.propositions, charge.propositions);
|
||||
assert.deepEqual(apres.retenu, charge.retenu);
|
||||
for (const placement of [apres.propositions[0], apres.retenu]) {
|
||||
assert.deepEqual(derive(placement, apres), [{ code: 'TOURS_CHANGES', avant: 3, maintenant: 4 }]);
|
||||
}
|
||||
});
|
||||
|
||||
test('« 0 » refuse MOINS_D_UN_TOUR {tours: 0}, « 2.5 » et « x » NON_ENTIER, sans écriture', async () => {
|
||||
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
|
||||
const avant = ecritures();
|
||||
await refuseAsync(seance.executer('changerTours', { tours: '0' }), 'MOINS_D_UN_TOUR', { tours: 0, remede: null });
|
||||
for (const tours of ['2.5', 'x']) {
|
||||
await refuseAsync(seance.executer('changerTours', { tours }), 'NON_ENTIER', { saisie: tours, remede: null });
|
||||
}
|
||||
assert.equal(ecritures(), avant);
|
||||
assert.equal(seance.charge.evenement.tours, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobilier : les réglages (§ 5.3, § 15.4)', () => {
|
||||
test('« Séparer les appartenances : désactivé », une entrée', async () => {
|
||||
const { seance, entrees } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2 }));
|
||||
const avant = entrees();
|
||||
const rendu = await seance.executer('modifierReglage', { cle: 'separerAppartenances', valeur: false });
|
||||
assert.equal(rendu.libelle, 'Séparer les appartenances : désactivé');
|
||||
assert.equal(entrees() - avant, 1);
|
||||
assert.equal(seance.charge.reglages.separerAppartenances, false);
|
||||
});
|
||||
|
||||
test('chaque réglage nommé se change ; une clé inconnue ou une valeur de forme fausse est une faute du code', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
|
||||
const attendus = [
|
||||
['nouveauxVoisins', false, 'Nouveaux voisins à chaque tour : désactivé'],
|
||||
['nouvelleTable', false, 'Nouvelle table à chaque tour : désactivé'],
|
||||
['varierAppartenances', false, 'Varier les appartenances rencontrées : désactivé'],
|
||||
['attribuerSieges', true, 'Attribuer les sièges : activé'],
|
||||
['generation.nombre', 8, 'Propositions par génération : 8'],
|
||||
['generation.arret', 300000, 'Mouvements par proposition : 300000'],
|
||||
['generation.historique', 500, "Historique d'acceptation : 500"],
|
||||
];
|
||||
for (const [cle, valeur, texte] of attendus) {
|
||||
const rendu = accepter('modifierReglage', charge, { cle, valeur });
|
||||
assert.equal(rendu.libelle, texte);
|
||||
const [tete, queue] = cle.split('.');
|
||||
assert.equal(queue === undefined ? rendu.charge.reglages[tete] : rendu.charge.reglages[tete][queue], valeur);
|
||||
}
|
||||
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'couleur', valeur: true }), TypeError);
|
||||
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'nouveauxVoisins', valeur: 'non' }), TypeError);
|
||||
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'generation.nombre', valeur: 0 }), TypeError);
|
||||
refuse(() => appliquerCommande('modifierReglage', charge, { cle: 'nouveauxVoisins', valeur: true }), 'SANS_EFFET');
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import { exporterRefus as exporterRefusCsv } from '../csv/import.js';
|
|||
import { libelle } from './libelles.js';
|
||||
|
||||
export { apercevoir } from '../csv/apercu.js';
|
||||
export { CHAMPS } from '../csv/colonnes.js';
|
||||
export { decoder } from '../csv/encodage.js';
|
||||
export { exporterParticipants } from '../csv/export.js';
|
||||
export { bilanRemplacement } from '../csv/import.js';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import * as apercu from '../csv/apercu.js';
|
||||
import * as colonnes from '../csv/colonnes.js';
|
||||
import * as encodage from '../csv/encodage.js';
|
||||
import * as exportCsv from '../csv/export.js';
|
||||
import * as importCsv from '../csv/import.js';
|
||||
|
|
@ -17,7 +18,7 @@ import * as relais from './csv.js';
|
|||
import { libelle } from './libelles.js';
|
||||
|
||||
describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
|
||||
test('decoder, apercevoir, exporterParticipants, bilanRemplacement et cleNormalisee sont ceux de src/csv', () => {
|
||||
test('decoder, apercevoir, exporterParticipants, bilanRemplacement, cleNormalisee et CHAMPS sont ceux de src/csv', () => {
|
||||
const attendus = [
|
||||
['decoder', encodage.decoder],
|
||||
['apercevoir', apercu.apercevoir],
|
||||
|
|
@ -30,8 +31,9 @@ describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
|
|||
assert.equal(typeof fonction, 'function', nom);
|
||||
assert.equal(relais[nom], fonction, nom);
|
||||
}
|
||||
assert.equal(relais.CHAMPS, colonnes.CHAMPS);
|
||||
assert.deepEqual(Object.keys(relais).sort(), [
|
||||
'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
|
||||
'CHAMPS', 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
34
src/application/diagnostic.js
Normal file
34
src/application/diagnostic.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le diagnostic de la configuration d'une charge (§ 5.6), pour l'interface,
|
||||
// qui n'importe pas le moteur (§ 13.4). Le calcul est celui de diagnostiquer,
|
||||
// sur configurationDepuisCharge : ce module n'en refait aucun chiffre.
|
||||
//
|
||||
// Une charge ouverte peut ne pas se normaliser — une table surréservée, que
|
||||
// le stockage admet et que le moteur refuse (CONFIGURATION_INCOHERENTE, A2).
|
||||
// L'ErreurConfiguration se rend alors par son code et ses détails, au lieu
|
||||
// de lever : le panneau dit le refus, et l'application reste vivante. Toute
|
||||
// autre erreur est une faute du code et remonte.
|
||||
import { diagnostiquer } from '../moteur/diagnostic.js';
|
||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { configurationDepuisCharge } from '../stockage/document.js';
|
||||
|
||||
/**
|
||||
* Le diagnostic de la configuration de la charge (§ 5.6). Ne modifie pas la
|
||||
* charge.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @returns {{diagnostic: import('../moteur/diagnostic.js').Diagnostic|null,
|
||||
* erreur: {code: string, details: Object}|null}}
|
||||
* diagnostic null et erreur, le code et les détails de l'ErreurConfiguration,
|
||||
* quand la configuration ne se normalise pas ; erreur null sinon
|
||||
*/
|
||||
export function diagnostiquerCharge(charge) {
|
||||
try {
|
||||
return { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null };
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
|
||||
return { diagnostic: null, erreur: { code: erreur.code, details: erreur.details } };
|
||||
}
|
||||
}
|
||||
83
src/application/diagnostic.test.js
Normal file
83
src/application/diagnostic.test.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du diagnostic d'une charge (§ 5.6, § 15.3) : la valeur rendue est
|
||||
// celle de diagnostiquer sur la configuration de la charge, sollicitée une
|
||||
// seconde fois (§ 19.1) ; une configuration qui ne se normalise pas rend son
|
||||
// code et ses détails, sans lever. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { texteLivre } from '../demo/livrees.js';
|
||||
import { diagnostiquer } from '../moteur/diagnostic.js';
|
||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { normaliser } from '../moteur/configuration.js';
|
||||
import { serialiser } from '../stockage/canonique.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { diagnostiquerCharge } from './diagnostic.js';
|
||||
|
||||
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
// La petite démonstration où quatre personnes portent une réservation de
|
||||
// portée « tous les tours », sans siège, à la table 1, de 3 places.
|
||||
function quatreReserveesSurTrois() {
|
||||
const charge = chargeDemo('petite');
|
||||
charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
return charge;
|
||||
}
|
||||
|
||||
describe('diagnostiquerCharge', () => {
|
||||
test('petite démonstration : le diagnostic de sa configuration, erreur null', () => {
|
||||
const charge = chargeDemo('petite');
|
||||
const rendu = diagnostiquerCharge(charge);
|
||||
assert.deepEqual(rendu, { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null });
|
||||
assert.ok(rendu.diagnostic.plafondAPriori.repartition.length > 0);
|
||||
});
|
||||
|
||||
test('petite démonstration au conflit inévitable : le plancher du groupe de 5', () => {
|
||||
const charge = chargeDemo('petite-conflit');
|
||||
const { diagnostic, erreur } = diagnostiquerCharge(charge);
|
||||
assert.equal(erreur, null);
|
||||
assert.equal(diagnostic.collisions.length, 1);
|
||||
assert.deepEqual(diagnostic, diagnostiquer(configurationDepuisCharge(charge)));
|
||||
});
|
||||
|
||||
test('quatre réservées sur une table de 3 : analyser admet, normaliser refuse ; le code rendu, sans lever', () => {
|
||||
const charge = quatreReserveesSurTrois();
|
||||
// Le fichier qui la porte se relit : la charge est admise par le stockage.
|
||||
const relue = analyser(serialiser(charge, { revision: 1, produitVersion: VERSION.affichee })).charge;
|
||||
assert.equal(relue.reservations.length, 4);
|
||||
let attendue = null;
|
||||
try {
|
||||
normaliser(configurationDepuisCharge(relue));
|
||||
} catch (erreur) {
|
||||
assert.ok(erreur instanceof ErreurConfiguration);
|
||||
attendue = erreur;
|
||||
}
|
||||
assert.notEqual(attendue, null);
|
||||
const rendu = diagnostiquerCharge(relue);
|
||||
assert.deepEqual(rendu, { diagnostic: null, erreur: { code: 'SURRESERVATION', details: attendue.details } });
|
||||
assert.equal(rendu.erreur.code, attendue.code);
|
||||
});
|
||||
|
||||
test('un événement neuf, sans participant ni table : un diagnostic, aucune erreur', () => {
|
||||
const charge = creerCharge({ id: 'evt-neuf', nom: 'Club des Merles', siegesParDefaut: 8, tours: 4 });
|
||||
const { diagnostic, erreur } = diagnostiquerCharge(charge);
|
||||
assert.equal(erreur, null);
|
||||
assert.deepEqual(diagnostic, diagnostiquer(configurationDepuisCharge(charge)));
|
||||
});
|
||||
|
||||
test('une erreur qui n\u{2019}est pas une ErreurConfiguration remonte', () => {
|
||||
const charge = chargeDemo('petite');
|
||||
charge.tables = null;
|
||||
assert.throws(() => diagnostiquerCharge(charge), TypeError);
|
||||
});
|
||||
|
||||
test('la charge reçue ne change pas', () => {
|
||||
const charge = quatreReserveesSurTrois();
|
||||
const avant = structuredClone(charge);
|
||||
diagnostiquerCharge(charge);
|
||||
diagnostiquerCharge(chargeDemo('grande'));
|
||||
assert.deepEqual(charge, avant);
|
||||
});
|
||||
});
|
||||
140
src/application/evaluation.js
Normal file
140
src/application/evaluation.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La table d'évaluation (§ 5.4, § 5.5, § 5.7, § 12.6, § 13.2) : chaque
|
||||
// proposition et le retenu, mesurés par la mesure qui fait foi, et le
|
||||
// classement de celles qui se comparent. Le classement, les colonnes et le
|
||||
// retenu la lisent ; ce module est le seul de l'application qui importe les
|
||||
// mesures du moteur, et il n'en refait aucune.
|
||||
//
|
||||
// Un placement se compare quand il décrit la configuration courante : sans
|
||||
// faute (fautes, S2) et sans dérive (derive, S2). Fautif ou en dérive, il
|
||||
// n'est ni mesuré ni classé, et ses raisons disent pourquoi — la partition
|
||||
// se fait avant classer, qui laisse passer deux populations de même taille.
|
||||
// derive et planDepuisFichier, qui lèvent sur une faute, ne reçoivent que
|
||||
// des placements sans faute.
|
||||
//
|
||||
// Les imposés viennent du diagnostic, les mêmes pour toute ligne ; les
|
||||
// rencontres répétées imposées dépendent du plan, et chaque ligne les lit
|
||||
// dans ses mesures, à part des choisies.
|
||||
import { classer } from '../moteur/classement.js';
|
||||
import { indexerPlan, normaliser } from '../moteur/configuration.js';
|
||||
import { diagnostiquer } from '../moteur/diagnostic.js';
|
||||
import { mesurer } from '../moteur/indicateurs.js';
|
||||
import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../moteur/manque.js';
|
||||
import { plafondsRealises } from '../moteur/plafond.js';
|
||||
import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js';
|
||||
import { configurationDepuisCharge } from '../stockage/document.js';
|
||||
import { derive, fautes, planDepuisFichier } from '../stockage/placements.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} LigneEvaluation
|
||||
* @property {number} id celui de la proposition ; retenu.proposition pour le retenu
|
||||
* @property {'comparable'|'derive'|'fautif'} statut
|
||||
* @property {object[]} raisons fautif : ses fautes ; derive : ses raisons ; [] sinon
|
||||
* @property {object[]} violations comparable : les réservations non honorées
|
||||
* (verifierInvariants, RESERVATION) ; [] sinon
|
||||
* @property {object|null} mesures mesurer, telles quelles
|
||||
* @property {number[]|null} plafondsRealises
|
||||
* @property {object|null} troisChiffres
|
||||
* @property {number|null} ecartAuPlafondAPrioriMax le plus grand de
|
||||
* ecartsAuPlafondAPriori ; null quand l'a priori est inconnu ou la
|
||||
* population vide
|
||||
* @property {boolean|null} minimumAtteint
|
||||
* @property {object[]} refusDessin verifierIndicateurs, chaque violation
|
||||
* complétée de participant, l'identifiant de son index ; []
|
||||
*
|
||||
* @typedef {Object} TableEvaluation
|
||||
* @property {number[]} population identifiants non exclus, croissants
|
||||
* @property {LigneEvaluation[]} propositions comparables dans l'ordre du
|
||||
* classement, puis les autres par identifiant
|
||||
* @property {LigneEvaluation|null} retenu
|
||||
* @property {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}} classement
|
||||
* classer, tel quel, sur les comparables
|
||||
* @property {{retours: number, ancrages: number}} imposes
|
||||
*/
|
||||
|
||||
// Le plus grand élément d'une liste ; null pour une liste vide ou absente.
|
||||
function plusGrand(valeurs) {
|
||||
if (valeurs === null || valeurs.length === 0) return null;
|
||||
let plus = valeurs[0];
|
||||
for (const valeur of valeurs) if (valeur > plus) plus = valeur;
|
||||
return plus;
|
||||
}
|
||||
|
||||
// Une ligne qui ne se mesure pas.
|
||||
const nonMesuree = (id, statut, raisons) => ({
|
||||
id,
|
||||
statut,
|
||||
raisons,
|
||||
violations: [],
|
||||
mesures: null,
|
||||
plafondsRealises: null,
|
||||
troisChiffres: null,
|
||||
ecartAuPlafondAPrioriMax: null,
|
||||
minimumAtteint: null,
|
||||
refusDessin: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* La table d'évaluation d'une charge (contrat de A6). Ne modifie pas la
|
||||
* charge.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {{plafondsAPriori?: 'calcul'|null}} [options]
|
||||
* 'calcul' : le plafond a priori par personne du diagnostic, une seule
|
||||
* maximisation ; null : a priori inconnu (§ 17, point 4)
|
||||
* @returns {TableEvaluation}
|
||||
* @throws {ErreurConfiguration} la configuration de la charge ne se
|
||||
* normalise pas : rien ne se mesure, et la génération refuse la même
|
||||
* @throws {TypeError} une autre valeur de plafondsAPriori
|
||||
*/
|
||||
export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
|
||||
if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) {
|
||||
throw new TypeError(`evaluer : plafondsAPriori 'calcul' ou null attendu, reçu ${JSON.stringify(plafondsAPriori)}`);
|
||||
}
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const diagnostic = diagnostiquer(configuration);
|
||||
const instance = normaliser(configuration);
|
||||
const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null;
|
||||
|
||||
// La ligne d'un placement, proposition ou retenu, sous l'identifiant id.
|
||||
function ligneDe(placement, id) {
|
||||
const sesFautes = fautes(placement);
|
||||
if (sesFautes.length > 0) return nonMesuree(id, 'fautif', sesFautes);
|
||||
const raisons = derive(placement, charge);
|
||||
if (raisons.length > 0) return nonMesuree(id, 'derive', raisons);
|
||||
const plan = planDepuisFichier(placement);
|
||||
const tableDe = indexerPlan(instance, plan);
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const realises = plafondsRealises(instance, tableDe);
|
||||
return {
|
||||
id,
|
||||
statut: 'comparable',
|
||||
raisons: [],
|
||||
violations: verifierInvariants(instance, plan, { reserveAutorisee: true }).filter(({ code }) => code === 'RESERVATION'),
|
||||
mesures,
|
||||
plafondsRealises: realises,
|
||||
troisChiffres: troisChiffres(instance, mesures, aPriori, realises),
|
||||
ecartAuPlafondAPrioriMax: plusGrand(ecartsAuPlafondAPriori(mesures, aPriori)),
|
||||
minimumAtteint: minimumAtteint(mesures, aPriori),
|
||||
refusDessin: verifierIndicateurs(mesures, aPriori, realises).map((violation) => ({
|
||||
...violation,
|
||||
participant: instance.ids[violation.index],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const lignes = charge.propositions.map((proposition) => ligneDe(proposition, proposition.id));
|
||||
const comparables = lignes.filter(({ statut }) => statut === 'comparable');
|
||||
const classement = classer(comparables.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori })));
|
||||
const rangDe = new Map(classement.ordre.map((id, rang) => [id, rang]));
|
||||
const autres = lignes.filter(({ statut }) => statut !== 'comparable').sort((a, b) => a.id - b.id);
|
||||
return {
|
||||
population: [...instance.ids],
|
||||
propositions: [...comparables.sort((a, b) => rangDe.get(a.id) - rangDe.get(b.id)), ...autres],
|
||||
retenu: charge.retenu === null ? null : ligneDe(charge.retenu, charge.retenu.proposition),
|
||||
classement,
|
||||
imposes: { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours },
|
||||
};
|
||||
}
|
||||
361
src/application/evaluation.test.js
Normal file
361
src/application/evaluation.test.js
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la table d'évaluation (§ 5.4, § 5.5, § 5.7, § 8.9, § 12.6,
|
||||
// § 13.2, § 15.1, § 15.3, § 15.4, § 17) : la mesure qui fait foi, le
|
||||
// classement, les placements en dérive ou fautifs, les imposés. Les charges
|
||||
// partent des démonstrations livrées, et leurs propositions s'enregistrent
|
||||
// par la commande ; les plans construits à la main sont écrits ici, sur la
|
||||
// petite démonstration au conflit inévitable. La valeur attendue vient des
|
||||
// fonctions du moteur, sollicitées une seconde fois (§ 19.1).
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { texteLivre } from '../demo/livrees.js';
|
||||
import { classer } from '../moteur/classement.js';
|
||||
import { indexerPlan, normaliser } from '../moteur/configuration.js';
|
||||
import { diagnostiquer } from '../moteur/diagnostic.js';
|
||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { mesurer } from '../moteur/indicateurs.js';
|
||||
import { ecartsAuPlafondAPriori } from '../moteur/manque.js';
|
||||
import { plafondsAPriori } from '../moteur/plafond.js';
|
||||
import { rechercher } from '../moteur/recherche.js';
|
||||
import { analyser, capacite, configurationDepuisCharge } from '../stockage/document.js';
|
||||
import { planDepuisFichier } from '../stockage/placements.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { appliquerCommande } from './commandes.js';
|
||||
import { evaluer } from './evaluation.js';
|
||||
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
// La charge, une génération du moteur de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||||
|
||||
// Une proposition écrite à la main, sièges non attribués, sur les tables de
|
||||
// la charge dans leur ordre ; ses listes et réserves telles que données.
|
||||
function propositionDe(charge, id, tours, reserves = tours.map(() => [])) {
|
||||
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
|
||||
return {
|
||||
id,
|
||||
graine: id,
|
||||
arret: 40,
|
||||
historique: 1000,
|
||||
produitVersion: V1,
|
||||
siegesAttribues: false,
|
||||
tables: charge.tables.map(({ id: t }) => t),
|
||||
capacites: charge.tables.map((table) => capacite(charge, table)),
|
||||
tours: tours.length,
|
||||
participants,
|
||||
placement: tours.map((listes, rang) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: reserves[rang] })),
|
||||
};
|
||||
}
|
||||
|
||||
// Le retenu tiré d'une proposition.
|
||||
function retenuDe(proposition) {
|
||||
const { id, siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(proposition);
|
||||
return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
|
||||
}
|
||||
|
||||
// Gèle en profondeur : evaluer qui modifierait sa charge lèverait.
|
||||
function figer(valeur) {
|
||||
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
|
||||
Object.freeze(valeur);
|
||||
for (const element of Object.values(valeur)) figer(element);
|
||||
}
|
||||
return valeur;
|
||||
}
|
||||
|
||||
// Chaque nombre atteint depuis la valeur, à toute profondeur.
|
||||
function nombres(valeur, trouves = []) {
|
||||
if (typeof valeur === 'number') trouves.push(valeur);
|
||||
else if (valeur !== null && typeof valeur === 'object') for (const element of Object.values(valeur)) nombres(element, trouves);
|
||||
return trouves;
|
||||
}
|
||||
|
||||
// Les plans écrits à la main sur la petite démonstration au conflit
|
||||
// inévitable — appartenances de tailles 5, 4 et 3 —, nommés par leurs
|
||||
// collisions (cumulées, distinctes). Les trois premiers ont le même plus
|
||||
// grand écart au plafond a priori, 3 ; le quatrième garde ensemble, aux
|
||||
// quatre tours, les personnes 1, 2 et 3, d'appartenances distinctes, et ses
|
||||
// quatre collisions tombent sur quatre paires.
|
||||
const PLANS_CONFLIT = [
|
||||
{
|
||||
nom: '(4, 1)',
|
||||
tours: [
|
||||
[[2, 3, 10], [6, 8, 9], [4, 7, 12], [1, 5, 11]],
|
||||
[[2, 7, 10], [4, 6, 8], [3, 11, 12], [1, 5, 9]],
|
||||
[[7, 9, 12], [3, 8, 11], [4, 6, 10], [1, 2, 5]],
|
||||
[[2, 7, 10], [3, 8, 9], [6, 11, 12], [1, 4, 5]],
|
||||
],
|
||||
collisions: [4, 1],
|
||||
},
|
||||
{
|
||||
nom: '(4, 2)',
|
||||
tours: [
|
||||
[[2, 6, 10], [3, 8, 9], [7, 11, 12], [1, 4, 5]],
|
||||
[[4, 7, 10], [2, 6, 8], [1, 5, 11], [3, 9, 12]],
|
||||
[[2, 6, 12], [4, 8, 10], [1, 3, 11], [5, 7, 9]],
|
||||
[[2, 3, 12], [4, 8, 10], [1, 6, 9], [5, 7, 11]],
|
||||
],
|
||||
collisions: [4, 2],
|
||||
},
|
||||
{
|
||||
nom: '(6, 6)',
|
||||
tours: [
|
||||
[[3, 4, 12], [7, 8, 9], [2, 6, 10], [1, 5, 11]],
|
||||
[[4, 6, 12], [2, 8, 10], [1, 7, 11], [3, 5, 9]],
|
||||
[[2, 4, 10], [6, 8, 11], [1, 3, 12], [5, 7, 9]],
|
||||
[[2, 7, 10], [5, 6, 8], [9, 11, 12], [1, 3, 4]],
|
||||
],
|
||||
collisions: [6, 6],
|
||||
},
|
||||
{
|
||||
nom: 'trio',
|
||||
tours: [
|
||||
[[1, 2, 3], [6, 9, 10], [7, 11, 12], [4, 5, 8]],
|
||||
[[1, 2, 3], [5, 6, 9], [7, 11, 12], [4, 8, 10]],
|
||||
[[1, 2, 3], [7, 8, 9], [10, 11, 12], [4, 5, 6]],
|
||||
[[1, 2, 3], [7, 8, 9], [6, 10, 11], [4, 5, 12]],
|
||||
],
|
||||
collisions: [4, 4],
|
||||
},
|
||||
];
|
||||
|
||||
describe("évaluation : la mesure qui fait foi (§ 5.10, § 13.2)", () => {
|
||||
test('chaque ligne comparable porte mesurer, les plafonds, les trois chiffres ; le classement est celui de classer', () => {
|
||||
const charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 }));
|
||||
const table = evaluer(charge);
|
||||
const instance = normaliser(configurationDepuisCharge(charge));
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
assert.deepEqual(table.population, instance.ids);
|
||||
assert.equal(table.propositions.length, 3);
|
||||
const entrees = [];
|
||||
for (const proposition of charge.propositions) {
|
||||
const ligne = table.propositions.find(({ id }) => id === proposition.id);
|
||||
const mesures = mesurer(instance, indexerPlan(instance, planDepuisFichier(proposition)));
|
||||
assert.equal(ligne.statut, 'comparable');
|
||||
assert.deepEqual(ligne.raisons, []);
|
||||
assert.deepEqual(ligne.violations, []);
|
||||
assert.deepEqual(ligne.refusDessin, []);
|
||||
assert.deepEqual(ligne.mesures, mesures);
|
||||
assert.equal(ligne.plafondsRealises.length, 12);
|
||||
assert.equal(ligne.minimumAtteint, false);
|
||||
assert.deepEqual(Object.keys(ligne.troisChiffres).sort(), ['ecartItineraireMax', 'effectifManque', 'manqueMax']);
|
||||
assert.equal(ligne.ecartAuPlafondAPrioriMax, Math.max(...ecartsAuPlafondAPriori(mesures, aPriori)));
|
||||
entrees.push({ id: proposition.id, mesures, plafondsAPriori: aPriori });
|
||||
}
|
||||
assert.deepEqual(table.classement, classer(entrees));
|
||||
assert.deepEqual(table.propositions.map(({ id }) => id), table.classement.ordre);
|
||||
assert.equal(table.retenu, null);
|
||||
});
|
||||
|
||||
test("trois plans au même premier critère se rangent par l'excédent, puis les cumulées ; le trio vient en dernier", () => {
|
||||
const charge = structuredClone(chargeDemo('petite-conflit'));
|
||||
charge.propositions = PLANS_CONFLIT.map(({ tours }, rang) => propositionDe(charge, rang + 1, tours));
|
||||
charge.prochainsIds.proposition = 5;
|
||||
charge.evenement.etat = 'propose';
|
||||
const table = evaluer(figer(charge));
|
||||
const parId = new Map(table.propositions.map((ligne) => [ligne.id, ligne]));
|
||||
PLANS_CONFLIT.forEach(({ nom, collisions }, rang) => {
|
||||
const { mesures } = parId.get(rang + 1);
|
||||
assert.deepEqual([mesures.collisionsCumulees, mesures.pairesDistinctes], collisions, nom);
|
||||
});
|
||||
assert.deepEqual([1, 2, 3].map((id) => parId.get(id).ecartAuPlafondAPrioriMax), [3, 3, 3]);
|
||||
assert.ok(parId.get(4).ecartAuPlafondAPrioriMax > 3);
|
||||
assert.deepEqual(table.classement.ordre, [3, 2, 1, 4]);
|
||||
assert.deepEqual(table.classement.criteresAppliques[0], 'ecartAuPlafondAPrioriMax');
|
||||
for (const ligne of table.propositions) {
|
||||
const ecarts = ecartsAuPlafondAPriori(ligne.mesures, plafondsAPriori(normaliser(configurationDepuisCharge(charge))));
|
||||
assert.equal(ligne.ecartAuPlafondAPrioriMax, Math.max(...ecarts));
|
||||
}
|
||||
});
|
||||
|
||||
test('evaluer ne modifie pas la charge, et deux appels rendent la même table', () => {
|
||||
const charge = figer(generee(chargeDemo('petite'), { graine: 2, nombre: 2, arret: 300 }));
|
||||
assert.deepEqual(evaluer(charge), evaluer(charge));
|
||||
});
|
||||
});
|
||||
|
||||
describe('évaluation : une réservation que le plan ne tient pas (§ 5.8, § 12.6)', () => {
|
||||
test("une réservation posée après la génération laisse la ligne comparable et classée, la violation RESERVATION nommée", () => {
|
||||
const avant = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 });
|
||||
const [proposition] = avant.propositions;
|
||||
// La table de la personne 1 au premier tour, puis une autre où la réserver.
|
||||
const listes = planDepuisFichier(proposition).tours[0];
|
||||
const sienne = avant.tables[listes.findIndex((liste) => liste.includes(1))].id;
|
||||
const autre = avant.tables.find(({ id }) => id !== sienne).id;
|
||||
const charge = executer(avant, 'reserver', { participant: 1, table: autre, siege: null, portee: 'tour', tour: 1 });
|
||||
const table = evaluer(figer(charge));
|
||||
const [ligne] = table.propositions;
|
||||
assert.equal(ligne.id, proposition.id);
|
||||
assert.equal(ligne.statut, 'comparable');
|
||||
assert.deepEqual(ligne.raisons, []);
|
||||
assert.deepEqual(ligne.violations, [{ code: 'RESERVATION', participant: 1, table: autre, tour: 1 }]);
|
||||
assert.notEqual(ligne.mesures, null);
|
||||
assert.deepEqual(table.classement.ordre, [proposition.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('évaluation : les formes closes ne corrigent rien (§ 5.4)', () => {
|
||||
test("un retenu qui laisse une personne en réserve à trois tours mesure moins de collisions que le plancher, et la table porte la mesure", () => {
|
||||
const charge = structuredClone(chargeDemo('petite-conflit'));
|
||||
// Le plan parfait de la petite démonstration, la personne 10 en réserve
|
||||
// aux tours 2 à 4 : elle seule fait deux membres d'une même appartenance
|
||||
// à une table, au premier tour.
|
||||
const tours = [
|
||||
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||
[[6, 9, 12], [3, 8, 11], [2, 5], [1, 4, 7]],
|
||||
[[4, 8], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
||||
[[5, 7, 11], [1, 9], [3, 4, 12], [2, 6, 8]],
|
||||
];
|
||||
const proposition = propositionDe(charge, 1, tours, [[], [10], [10], [10]]);
|
||||
charge.propositions = [proposition];
|
||||
charge.retenu = retenuDe(proposition);
|
||||
charge.prochainsIds.proposition = 2;
|
||||
charge.evenement.etat = 'retenu';
|
||||
const table = evaluer(figer(charge));
|
||||
const plancher = diagnostiquer(configurationDepuisCharge(charge)).collisions.reduce((total, { plancherCumulees }) => total + plancherCumulees, 0);
|
||||
assert.equal(plancher, 4);
|
||||
assert.equal(table.retenu.statut, 'comparable');
|
||||
assert.equal(table.retenu.id, 1);
|
||||
assert.equal(table.retenu.mesures.collisionsCumulees, 1);
|
||||
assert.ok(table.retenu.mesures.collisionsCumulees < plancher);
|
||||
});
|
||||
});
|
||||
|
||||
describe('évaluation : les populations différentes (§ 9, § 12.6)', () => {
|
||||
test("générer, exclure une personne, générer encore : les premières sont en dérive, hors classement, leurs raisons nomment la personne", () => {
|
||||
let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 });
|
||||
charge = executer(charge, 'exclureParticipant', { id: 4 });
|
||||
charge = generee(charge, { graine: 2, nombre: 2, arret: 200 });
|
||||
const table = evaluer(figer(charge));
|
||||
assert.deepEqual(table.population, [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
assert.deepEqual(table.classement.ordre.slice().sort(), [3, 4]);
|
||||
for (const id of [1, 2]) {
|
||||
const ligne = table.propositions.find((candidate) => candidate.id === id);
|
||||
assert.equal(ligne.statut, 'derive');
|
||||
assert.deepEqual(ligne.raisons, [{ code: 'PARTICIPANT_EXCLU', participant: 4 }]);
|
||||
assert.deepEqual([ligne.mesures, ligne.plafondsRealises, ligne.troisChiffres, ligne.ecartAuPlafondAPrioriMax, ligne.minimumAtteint], [null, null, null, null, null]);
|
||||
assert.deepEqual([ligne.violations, ligne.refusDessin], [[], []]);
|
||||
}
|
||||
assert.deepEqual(table.propositions.map(({ id }) => id), [...table.classement.ordre, 1, 2]);
|
||||
});
|
||||
|
||||
test('une exclusion et un ajout, à effectif égal, mettent en dérive ; une table ajoutée aussi, TABLE_AJOUTEE', () => {
|
||||
let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 });
|
||||
charge = executer(charge, 'exclureParticipant', { id: 4 });
|
||||
charge = executer(charge, 'ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles' } });
|
||||
const echange = evaluer(figer(charge));
|
||||
assert.equal(echange.population.length, 12);
|
||||
assert.equal(echange.propositions[0].statut, 'derive');
|
||||
assert.deepEqual(echange.propositions[0].raisons.map(({ code }) => code), ['PARTICIPANT_EXCLU', 'PARTICIPANT_NON_PLACE']);
|
||||
assert.deepEqual(echange.classement.ordre, []);
|
||||
let ajout = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 });
|
||||
ajout = executer(ajout, 'creerTable', { forme: 'ronde' });
|
||||
const table = evaluer(figer(ajout));
|
||||
assert.equal(table.propositions[0].statut, 'derive');
|
||||
assert.deepEqual(table.propositions[0].raisons, [{ code: 'TABLE_AJOUTEE', table: 5 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('évaluation : un placement fautif (§ 8.9, point 3)', () => {
|
||||
test("un retenu dont une liste dépasse sa longueur déclarée est fautif, non mesuré, et les propositions se classent sans lui", () => {
|
||||
const base = generee(chargeDemo('petite'), { graine: 3, nombre: 2, arret: 300 });
|
||||
const attendue = evaluer(figer(structuredClone(base)));
|
||||
const charge = structuredClone(base);
|
||||
const retenu = retenuDe(charge.propositions[1]);
|
||||
const [premiere, seconde] = retenu.placement[0].sieges;
|
||||
premiere.push(seconde.shift());
|
||||
charge.retenu = retenu;
|
||||
charge.evenement.etat = 'retenu';
|
||||
const table = evaluer(figer(charge));
|
||||
assert.equal(table.retenu.statut, 'fautif');
|
||||
assert.equal(table.retenu.id, charge.propositions[1].id);
|
||||
assert.equal(table.retenu.mesures, null);
|
||||
assert.ok(table.retenu.raisons.length > 0);
|
||||
assert.ok(table.retenu.raisons.every(({ code }) => code === 'LONGUEUR'), JSON.stringify(table.retenu.raisons));
|
||||
assert.deepEqual(table.propositions, attendue.propositions);
|
||||
assert.deepEqual(table.classement, attendue.classement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('évaluation : le plafond a priori inconnu (§ 17, point 4)', () => {
|
||||
test("plafondsAPriori null : le premier critère est sauté et nommé ; écart d'itinéraire et certificat inconnus", () => {
|
||||
const charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 2, arret: 300 }));
|
||||
const table = evaluer(charge, { plafondsAPriori: null });
|
||||
assert.equal(table.classement.critereSaute, 'ecartAuPlafondAPrioriMax');
|
||||
assert.equal(table.classement.criteresAppliques[0], 'excedentCollisions');
|
||||
for (const ligne of table.propositions) {
|
||||
assert.equal(ligne.troisChiffres.ecartItineraireMax, null);
|
||||
assert.equal(ligne.minimumAtteint, null);
|
||||
assert.equal(ligne.ecartAuPlafondAPrioriMax, null);
|
||||
}
|
||||
assert.throws(() => evaluer(charge, { plafondsAPriori: [] }), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('évaluation : les imposés (§ 5.4, § 15.1)', () => {
|
||||
test('la grande démonstration : 99 retours imposés, 33 ancrages qui en imposent, ceux de diagnostiquer', () => {
|
||||
const charge = chargeDemo('grande');
|
||||
const table = evaluer(figer(charge));
|
||||
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
|
||||
assert.deepEqual(table.imposes, { retours: 99, ancrages: 33 });
|
||||
assert.deepEqual(table.imposes, { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours });
|
||||
});
|
||||
|
||||
test("deux personnes d'appartenances distinctes réservées ensemble : une rencontre répétée imposée par ligne, hors des choisies", () => {
|
||||
const charge = structuredClone(chargeDemo('petite'));
|
||||
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
const avec = generee(charge, { graine: 6, nombre: 3, arret: 500 });
|
||||
const table = evaluer(figer(avec));
|
||||
assert.equal(table.propositions.length, 3);
|
||||
for (const ligne of table.propositions) {
|
||||
assert.equal(ligne.mesures.rencontresRepetees.imposees, 1);
|
||||
// Les paires réunies deux fois au moins, comptées sur le plan : celle
|
||||
// des deux réservés n'est pas parmi les choisies.
|
||||
const plan = planDepuisFichier(avec.propositions.find(({ id }) => id === ligne.id));
|
||||
const fois = new Map();
|
||||
for (const listes of plan.tours) {
|
||||
for (const liste of listes) {
|
||||
for (const a of liste) for (const b of liste) if (a < b) fois.set(`${a}-${b}`, (fois.get(`${a}-${b}`) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const repetees = [...fois.values()].filter((n) => n >= 2).length;
|
||||
assert.equal(fois.get('1-2'), 4);
|
||||
assert.equal(ligne.mesures.rencontresRepetees.choisies, repetees - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('évaluation : une table, un tour (§ 15.4)', () => {
|
||||
test('six personnes à une table de 6, un tour : rien de répété, aucun NaN ni infini', () => {
|
||||
const charge = structuredClone(chargeDemo('petite'));
|
||||
charge.participants = charge.participants.slice(0, 6);
|
||||
charge.tables = charge.tables.slice(0, 1).map((table) => ({ ...table, sieges: 6 }));
|
||||
charge.evenement.tours = 1;
|
||||
const table = evaluer(figer(generee(charge, { graine: 1, nombre: 1, arret: 10 })));
|
||||
const [ligne] = table.propositions;
|
||||
assert.equal(ligne.statut, 'comparable');
|
||||
assert.deepEqual(ligne.mesures.rencontresRepetees, { choisies: 0, imposees: 0 });
|
||||
assert.equal(ligne.mesures.excedentCollisions, 0);
|
||||
assert.equal(ligne.mesures.totalRetoursChoisis, 0);
|
||||
const tous = nombres(table);
|
||||
assert.ok(tous.length > 0);
|
||||
assert.ok(tous.every(Number.isFinite), 'NaN ou infini dans la table');
|
||||
});
|
||||
});
|
||||
|
||||
describe("évaluation : une configuration qui ne se tient pas", () => {
|
||||
test("evaluer lève l'ErreurConfiguration de normaliser", () => {
|
||||
const charge = structuredClone(chargeDemo('petite'));
|
||||
charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
assert.throws(() => evaluer(figer(charge)), (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION');
|
||||
});
|
||||
});
|
||||
97
src/application/executeur_travailleur.js
Normal file
97
src/application/executeur_travailleur.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'exécuteur de production de la génération (§ 5.10, § 13.4) : la
|
||||
// recherche tourne dans un travailleur, hors du fil de l'interface, et
|
||||
// l'exécuteur adapte son protocole — celui de generation_travailleur.js —
|
||||
// au contrat Executeur du contrôleur de génération (generation.js).
|
||||
//
|
||||
// → { type: 'lancer', configuration, reglages } le premier et seul message
|
||||
// ← { type: 'avancement', fait, total } remis aux abonnés
|
||||
// ← { type: 'resultat', propositions } résout le résultat
|
||||
// ← { type: 'erreur', code, details } rejette par ErreurGeneration
|
||||
//
|
||||
// Chaque lancer prend un travailleur neuf de la fabrique et le termine à
|
||||
// l'issue : résultat, erreur, faute du travailleur ou annulation. Terminé,
|
||||
// le travailleur ne remet plus rien : un message déjà en file à cet instant
|
||||
// ne parvient ni aux abonnés ni au résultat. L'annulation est terminate(),
|
||||
// dans le même tour : immédiate, sans résultat partiel. La fabrique est un
|
||||
// paramètre : src/main.js la construit sur le travailleur inclus dans la
|
||||
// page, une épreuve sur un travailleur factice. Ce module ne touche aucune
|
||||
// plateforme.
|
||||
import { ErreurAnnulee, ErreurGeneration } from './generation.js';
|
||||
|
||||
/**
|
||||
* L'exécuteur du travailleur.
|
||||
*
|
||||
* @param {() => {postMessage: (message: Object) => void, terminate: () => void,
|
||||
* onmessage: ((evenement: {data: Object}) => void)|null,
|
||||
* onerror?: ((evenement: {message?: string}) => void)|null}} fabrique
|
||||
* rend un travailleur neuf à chaque appel
|
||||
* @returns {import('./generation.js').Executeur}
|
||||
* lancer(configuration, reglages) rend une Execution : avancement →
|
||||
* surAvancement ; resultat → la promesse résolue aux propositions reçues,
|
||||
* puis terminate() ; erreur {code, details} → rejet par
|
||||
* ErreurGeneration(code, details) ; faute du travailleur, son canal
|
||||
* d'erreur, → rejet par une Error sans code, qui remonte comme une faute ;
|
||||
* annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun
|
||||
* avancement. Après l'issue, annuler() ne fait rien.
|
||||
*/
|
||||
export function creerExecuteurTravailleur(fabrique) {
|
||||
return {
|
||||
lancer(configuration, reglages) {
|
||||
const travailleur = fabrique();
|
||||
const abonnes = new Set();
|
||||
let termine = false;
|
||||
let resoudre;
|
||||
let rejeter;
|
||||
const resultat = new Promise((ok, ko) => {
|
||||
resoudre = ok;
|
||||
rejeter = ko;
|
||||
});
|
||||
|
||||
// L'issue : le travailleur se termine, ses gestionnaires sont retirés,
|
||||
// et rien de ce qu'il remettrait encore ne passe.
|
||||
function terminer() {
|
||||
termine = true;
|
||||
travailleur.onmessage = null;
|
||||
travailleur.onerror = null;
|
||||
travailleur.terminate();
|
||||
}
|
||||
|
||||
travailleur.onmessage = ({ data }) => {
|
||||
if (termine) return;
|
||||
if (data.type === 'avancement') {
|
||||
for (const fn of [...abonnes]) fn(data.fait, data.total);
|
||||
} else if (data.type === 'resultat') {
|
||||
resoudre(data.propositions);
|
||||
terminer();
|
||||
} else if (data.type === 'erreur') {
|
||||
rejeter(new ErreurGeneration(data.code, data.details));
|
||||
terminer();
|
||||
}
|
||||
};
|
||||
travailleur.onerror = (evenement) => {
|
||||
if (termine) return;
|
||||
rejeter(new Error(`travailleur de génération : ${evenement?.message ?? 'faute sans message'}`));
|
||||
terminer();
|
||||
};
|
||||
travailleur.postMessage({ type: 'lancer', configuration, reglages });
|
||||
|
||||
return {
|
||||
surAvancement(fn) {
|
||||
abonnes.add(fn);
|
||||
return () => {
|
||||
abonnes.delete(fn);
|
||||
};
|
||||
},
|
||||
resultat,
|
||||
annuler() {
|
||||
if (termine) return;
|
||||
terminer();
|
||||
rejeter(new ErreurAnnulee());
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
174
src/application/executeur_travailleur.test.js
Normal file
174
src/application/executeur_travailleur.test.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de l'exécuteur du travailleur (§ 5.10) : creerExecuteurTravailleur
|
||||
// sur une fabrique de travailleurs factices. Un travailleur factice garde
|
||||
// chaque message reçu et compte ses terminate() ; l'épreuve lui fait poster
|
||||
// les messages du contrat de données par son onmessage, comme le ferait le
|
||||
// vrai travailleur. Aucun calcul n'a lieu : les propositions et la
|
||||
// configuration sont des objets d'épreuve.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { creerExecuteurTravailleur } from './executeur_travailleur.js';
|
||||
import { ErreurAnnulee, ErreurGeneration } from './generation.js';
|
||||
|
||||
const CONFIGURATION = Object.freeze({ marque: 'configuration d’épreuve', tours: 3 });
|
||||
const REGLAGES = Object.freeze({ graine: 11, nombre: 2, arret: 40_000, historique: 1000 });
|
||||
|
||||
// Une fabrique de travailleurs factices, et les travailleurs qu'elle a
|
||||
// rendus, dans l'ordre. Chacun : recus, les messages reçus, clonés ;
|
||||
// terminaisons, le compte de terminate() ; poster(data), un message du
|
||||
// travailleur, remis à son onmessage courant.
|
||||
function fabriqueFactice() {
|
||||
const travailleurs = [];
|
||||
const fabrique = () => {
|
||||
const travailleur = {
|
||||
recus: [],
|
||||
terminaisons: 0,
|
||||
onmessage: null,
|
||||
postMessage(message) {
|
||||
this.recus.push(structuredClone(message));
|
||||
},
|
||||
terminate() {
|
||||
this.terminaisons += 1;
|
||||
},
|
||||
poster(data) {
|
||||
this.onmessage?.({ data });
|
||||
},
|
||||
};
|
||||
travailleurs.push(travailleur);
|
||||
return travailleur;
|
||||
};
|
||||
return { fabrique, travailleurs };
|
||||
}
|
||||
|
||||
// L'issue d'une promesse, sans attendre plus d'un tour de la file des
|
||||
// microtâches : { etat: 'attente' }, { etat: 'resolue', valeur } ou
|
||||
// { etat: 'rejetee', erreur }.
|
||||
async function issue(promesse) {
|
||||
let lue = { etat: 'attente' };
|
||||
promesse.then(
|
||||
(valeur) => {
|
||||
lue = { etat: 'resolue', valeur };
|
||||
},
|
||||
(erreur) => {
|
||||
lue = { etat: 'rejetee', erreur };
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
return lue;
|
||||
}
|
||||
|
||||
describe('exécuteur du travailleur (§ 5.10)', () => {
|
||||
test('un travailleur neuf par lancer, dont le premier message est { type: lancer, configuration, reglages }', () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const executeur = creerExecuteurTravailleur(fabrique);
|
||||
assert.equal(travailleurs.length, 0, 'aucun travailleur avant le premier lancer');
|
||||
const premiere = executeur.lancer(CONFIGURATION, REGLAGES);
|
||||
const seconde = executeur.lancer(CONFIGURATION, { ...REGLAGES, graine: 12 });
|
||||
premiere.resultat.catch(() => undefined);
|
||||
seconde.resultat.catch(() => undefined);
|
||||
assert.equal(travailleurs.length, 2);
|
||||
assert.notEqual(travailleurs[0], travailleurs[1]);
|
||||
assert.deepEqual(travailleurs[0].recus, [{ type: 'lancer', configuration: CONFIGURATION, reglages: REGLAGES }]);
|
||||
assert.deepEqual(travailleurs[1].recus, [
|
||||
{ type: 'lancer', configuration: CONFIGURATION, reglages: { ...REGLAGES, graine: 12 } },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
travailleurs.map(({ terminaisons }) => terminaisons),
|
||||
[0, 0],
|
||||
);
|
||||
});
|
||||
|
||||
test('deux avancements parviennent à surAvancement dans leur ordre ; un abonné retiré ne reçoit plus rien', () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
execution.resultat.catch(() => undefined);
|
||||
const recus = [];
|
||||
const retires = [];
|
||||
execution.surAvancement((fait, total) => recus.push([fait, total]));
|
||||
const desabonner = execution.surAvancement((fait, total) => retires.push([fait, total]));
|
||||
const [travailleur] = travailleurs;
|
||||
travailleur.poster({ type: 'avancement', fait: 50_000, total: 80_000 });
|
||||
desabonner();
|
||||
travailleur.poster({ type: 'avancement', fait: 80_000, total: 80_000 });
|
||||
assert.deepEqual(recus, [
|
||||
[50_000, 80_000],
|
||||
[80_000, 80_000],
|
||||
]);
|
||||
assert.deepEqual(retires, [[50_000, 80_000]]);
|
||||
});
|
||||
|
||||
test('resultat : la promesse se résout aux propositions reçues, puis le travailleur est terminé ; un message tardif ne change rien', async () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
const avancements = [];
|
||||
execution.surAvancement((fait) => avancements.push(fait));
|
||||
const [travailleur] = travailleurs;
|
||||
const propositions = [
|
||||
{ id: 1, graine: 101, arret: 40_000, historique: 1000, plan: [[[1, 2]]] },
|
||||
{ id: 2, graine: 102, arret: 40_000, historique: 1000, plan: [[[2, 1]]] },
|
||||
];
|
||||
assert.equal((await issue(execution.resultat)).etat, 'attente');
|
||||
travailleur.poster({ type: 'resultat', propositions });
|
||||
const lue = await issue(execution.resultat);
|
||||
assert.equal(lue.etat, 'resolue');
|
||||
assert.deepEqual(lue.valeur, propositions);
|
||||
assert.equal(travailleur.terminaisons, 1);
|
||||
travailleur.poster({ type: 'avancement', fait: 1, total: 2 });
|
||||
execution.annuler();
|
||||
assert.deepEqual(avancements, []);
|
||||
assert.equal(travailleur.terminaisons, 1, 'annuler après le résultat ne termine pas une seconde fois');
|
||||
});
|
||||
|
||||
test('erreur { code, details } : rejet par une ErreurGeneration de ce code et de ces détails, le travailleur terminé', async () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
const [travailleur] = travailleurs;
|
||||
travailleur.poster({ type: 'erreur', code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } });
|
||||
const lue = await issue(execution.resultat);
|
||||
assert.equal(lue.etat, 'rejetee');
|
||||
assert.ok(lue.erreur instanceof ErreurGeneration, String(lue.erreur));
|
||||
assert.equal(lue.erreur.code, 'PLACES_MANQUANTES');
|
||||
assert.deepEqual(lue.erreur.details, { placesManquantes: 1 });
|
||||
assert.equal(travailleur.terminaisons, 1);
|
||||
});
|
||||
|
||||
test("annuler : terminate() aussitôt, rejet par ErreurAnnulee ; un avancement ou un résultat posté ensuite n'atteint plus rien", async () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
const avancements = [];
|
||||
execution.surAvancement((fait, total) => avancements.push([fait, total]));
|
||||
const [travailleur] = travailleurs;
|
||||
// Le gestionnaire posé au lancement : un message déjà en file chez le
|
||||
// navigateur au moment de l'annulation l'atteindrait encore.
|
||||
const gestionnaire = travailleur.onmessage;
|
||||
travailleur.poster({ type: 'avancement', fait: 50_000, total: 80_000 });
|
||||
execution.annuler();
|
||||
assert.equal(travailleur.terminaisons, 1, 'terminate() dans le même tour que annuler()');
|
||||
gestionnaire({ data: { type: 'avancement', fait: 80_000, total: 80_000 } });
|
||||
gestionnaire({ data: { type: 'resultat', propositions: [] } });
|
||||
travailleur.poster({ type: 'avancement', fait: 80_000, total: 80_000 });
|
||||
const lue = await issue(execution.resultat);
|
||||
assert.equal(lue.etat, 'rejetee');
|
||||
assert.ok(lue.erreur instanceof ErreurAnnulee, String(lue.erreur));
|
||||
assert.deepEqual(avancements, [[50_000, 80_000]]);
|
||||
execution.annuler();
|
||||
assert.equal(travailleur.terminaisons, 1, 'une seconde annulation ne termine rien de plus');
|
||||
});
|
||||
|
||||
test('le canal d’erreur du travailleur : rejet par une erreur sans code, le travailleur terminé', async () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
const [travailleur] = travailleurs;
|
||||
assert.equal(typeof travailleur.onerror, 'function');
|
||||
travailleur.onerror({ message: 'faute d’épreuve' });
|
||||
const lue = await issue(execution.resultat);
|
||||
assert.equal(lue.etat, 'rejetee');
|
||||
assert.ok(lue.erreur instanceof Error);
|
||||
assert.equal(lue.erreur.code, undefined);
|
||||
assert.match(lue.erreur.message, /faute d’épreuve/);
|
||||
assert.equal(travailleur.terminaisons, 1);
|
||||
});
|
||||
});
|
||||
441
src/application/generation.js
Normal file
441
src/application/generation.js
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La génération (§ 5.7, § 5.8, § 5.10) : les exécuteurs qui font tourner la
|
||||
// recherche du moteur, et le contrôleur qui la lance depuis la séance.
|
||||
//
|
||||
// Un exécuteur rend, pour une configuration et des réglages, une exécution :
|
||||
// son résultat, une promesse des propositions de rechercher ; ses
|
||||
// avancements ; son annulation, qui rejette le résultat par ErreurAnnulee,
|
||||
// sans résultat partiel. L'exécuteur direct calcule dans le fil, une
|
||||
// microtâche après le lancement : il sert les épreuves et le premier
|
||||
// montage de l'application ; celui du travailleur (I13) calcule hors du fil
|
||||
// et s'annule par terminate(). Une erreur de la recherche devient la même
|
||||
// dans les deux, par versErreurGeneration.
|
||||
//
|
||||
// Le contrôleur ne numérote rien et ne mesure aucun plan. Il refuse avant
|
||||
// tout lancement un réglage hors de son domaine (REGLAGE_HORS_DOMAINE), puis
|
||||
// ce que l'enregistrement refuserait — les refus de la séance, lus dans son
|
||||
// état observable, dans l'ordre de son contrat —, puis
|
||||
// ce qui rend la génération vaine ou impossible ; il garde la graine
|
||||
// proposée et la durée du dernier calcul, en mémoire, pour la séance de
|
||||
// l'application ; il vérifie les invariants de chaque proposition rendue,
|
||||
// écarte celles dont le plan est déjà présent, et confie les autres à la
|
||||
// commande enregistrerGeneration, une seule entrée du journal. Le nombre de
|
||||
// propositions d'une génération est un réglage, jamais une constante d'ici.
|
||||
//
|
||||
// Le compte d'inactivité de la séance est suspendu pendant toute la
|
||||
// génération (debuterCalcul), et repart à sa fin, quelle qu'elle soit
|
||||
// (finirCalcul, dans un finally). Passer en lecture, ou fermer l'événement,
|
||||
// annule la génération en cours (surAnnulation).
|
||||
import { normaliser, nombrePlacesManquantes } from '../moteur/configuration.js';
|
||||
import { ErreurAnnulee, ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { rechercher, verifierReglages } from '../moteur/recherche.js';
|
||||
import { verifierInvariants } from '../moteur/verification.js';
|
||||
import { GENERATION_PAR_DEFAUT, configurationDepuisCharge } from '../stockage/document.js';
|
||||
import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js';
|
||||
import { lireEntier } from './capacite.js';
|
||||
|
||||
// src/interface reçoit l'annulation d'ici : elle ne joint pas le moteur.
|
||||
export { ErreurAnnulee } from '../moteur/erreurs.js';
|
||||
|
||||
/** Au-delà de cette durée estimée, en secondes, la génération demande confirmation (§ 5.10). */
|
||||
export const SEUIL_GENERATION_LONGUE_S = 60;
|
||||
|
||||
/** Le plafond d'usage d'une génération, en secondes (§ 5.10) : un repère dit, jamais un refus. */
|
||||
export const PLAFOND_USAGE_S = 30 * 60;
|
||||
|
||||
// Sans durée mesurée, une génération de plus de mouvements que ce compte —
|
||||
// dix fois le réglage par défaut — demande confirmation.
|
||||
const MOUVEMENTS_SANS_MESURE = 10 * GENERATION_PAR_DEFAUT.nombre * GENERATION_PAR_DEFAUT.arret;
|
||||
|
||||
// La plus grande graine, et le modulo de la suivante.
|
||||
const GRAINE_MAX = 2 ** 32 - 1;
|
||||
const GRAINES = 2 ** 32;
|
||||
|
||||
// Les champs des réglages, dans l'ordre où leurs refus se rendent, et le
|
||||
// domaine de chacun ; max null : sans borne.
|
||||
const CHAMPS = [
|
||||
['graine', 0, GRAINE_MAX],
|
||||
['nombre', 1, null],
|
||||
['arret', 1, null],
|
||||
['historique', 1, null],
|
||||
];
|
||||
|
||||
const versSection = (section) => ({ geste: 'ouvrirSection', section });
|
||||
|
||||
/**
|
||||
* Refus ou échec d'une génération : son code et ses détails — ceux de la
|
||||
* séance, du moteur ou du contrôleur, tels quels. Le texte affiché est celui
|
||||
* de son code, nu (§ 14.6) ; le message, code et détails en JSON, sert aux
|
||||
* traces.
|
||||
*/
|
||||
export class ErreurGeneration extends Error {
|
||||
/**
|
||||
* @param {string} code
|
||||
* @param {Object} [details]
|
||||
*/
|
||||
constructor(code, details = {}) {
|
||||
super(`${code} ${JSON.stringify(details)}`);
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
ErreurGeneration.prototype.name = 'ErreurGeneration';
|
||||
|
||||
/**
|
||||
* Les réglages d'une génération, contrôlés par la règle de rechercher
|
||||
* (verifierReglages) avant tout calcul. Lève, pour le premier réglage hors
|
||||
* de son domaine, ErreurGeneration('REGLAGE_HORS_DOMAINE', { reglage,
|
||||
* remede: null }), reglage étant son nom — graine, arret, historique ou
|
||||
* nombre —, sans le message du moteur ; ne rend rien sinon. Toute autre
|
||||
* erreur du contrôle est relevée telle quelle.
|
||||
*
|
||||
* @param {Object} reglages
|
||||
*/
|
||||
export function exigerReglages(reglages) {
|
||||
try {
|
||||
verifierReglages(reglages);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof RangeError && typeof erreur.reglage === 'string') {
|
||||
throw new ErreurGeneration('REGLAGE_HORS_DOMAINE', { reglage: erreur.reglage, remede: null });
|
||||
}
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L'erreur d'une recherche, la même dans les deux exécuteurs : ErreurAnnulee
|
||||
* telle quelle ; une ErreurConfiguration devient ErreurGeneration de son code
|
||||
* et de ses détails ; toute autre, RangeError comprise, est rendue telle
|
||||
* quelle, pour être relevée. Les réglages sont contrôlés avant le calcul
|
||||
* (exigerReglages) : une RangeError qui survient pendant lui vient d'ailleurs,
|
||||
* d'un abonné aux avancements par exemple, et ne se dit pas comme un réglage.
|
||||
*
|
||||
* @param {unknown} erreur
|
||||
* @returns {unknown}
|
||||
*/
|
||||
export function versErreurGeneration(erreur) {
|
||||
if (erreur instanceof ErreurAnnulee) return erreur;
|
||||
if (erreur instanceof ErreurConfiguration) return new ErreurGeneration(erreur.code, erreur.details);
|
||||
return erreur;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} Execution
|
||||
* @property {(fn: (fait: number, total: number) => void) => () => void} surAvancement
|
||||
* abonne fn aux avancements ; rend la fonction qui désabonne
|
||||
* @property {Promise<import('../moteur/recherche.js').Proposition[]>} resultat
|
||||
* rejetée par ErreurAnnulee à l'annulation, par ErreurGeneration sur
|
||||
* une erreur du moteur
|
||||
* @property {() => void} annuler sans résultat partiel
|
||||
*
|
||||
* @typedef {{lancer: (configuration: Object, reglages: Object) => Execution}} Executeur
|
||||
*/
|
||||
|
||||
/**
|
||||
* L'exécuteur direct : rechercher dans le fil, une microtâche après lancer.
|
||||
* Rien d'autre ne s'exécute pendant la recherche. annuler(), avant ce départ
|
||||
* ou depuis un rappel d'avancement, fait lever ErreurAnnulee à la lecture
|
||||
* suivante du signal par le moteur : avant la proposition suivante, au plus
|
||||
* 1 000 mouvements plus tard. Chaque avancement est celui du moteur, toutes
|
||||
* les 1 000 itérations.
|
||||
*
|
||||
* @returns {Executeur}
|
||||
*/
|
||||
export function creerExecuteurDirect() {
|
||||
return {
|
||||
lancer(configuration, reglages) {
|
||||
const signal = { aborted: false };
|
||||
const abonnes = new Set();
|
||||
const progression = (fait, total) => {
|
||||
for (const fn of [...abonnes]) fn(fait, total);
|
||||
};
|
||||
const resultat = Promise.resolve().then(() => {
|
||||
exigerReglages(reglages);
|
||||
try {
|
||||
return rechercher(configuration, reglages, { signal, progression });
|
||||
} catch (erreur) {
|
||||
throw versErreurGeneration(erreur);
|
||||
}
|
||||
});
|
||||
return {
|
||||
surAvancement(fn) {
|
||||
abonnes.add(fn);
|
||||
return () => {
|
||||
abonnes.delete(fn);
|
||||
};
|
||||
},
|
||||
resultat,
|
||||
annuler() {
|
||||
signal.aborted = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Le plan d'une proposition du moteur, rangé comme planDepuisFichier rend
|
||||
// celui d'une proposition enregistrée : la même mise en forme, versFichier
|
||||
// puis planDepuisFichier, pour que deux plans égaux s'écrivent pareil.
|
||||
function planRange(proposition, configuration, produitVersion) {
|
||||
const fichier = versFichier(proposition, configuration, { produitVersion, attribuerSieges: false, decalage: 0 });
|
||||
return JSON.stringify(planDepuisFichier(fichier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le contrôleur de génération d'une séance (§ 5.7, § 5.10).
|
||||
*
|
||||
* @param {Object} parametres
|
||||
* @param {Object} parametres.seance la séance d'A1 et A2 : etat,
|
||||
* subscribe, surAnnulation, executer, debuterCalcul, finirCalcul
|
||||
* @param {Executeur} parametres.executeur
|
||||
* @param {import('./horloge.js').Horloge} parametres.horloge instant() mesure
|
||||
* la durée d'un calcul
|
||||
* @param {string} parametres.produitVersion
|
||||
* @returns {Object} le contrôleur : grainePropose, reglagesProposes, valider,
|
||||
* estimer, derniereGeneration, generer, annuler, subscribe
|
||||
*/
|
||||
export function creerControleurGeneration({ seance, executeur, horloge, produitVersion }) {
|
||||
// graine : la graine proposée ; base : celle de l'événement ouvert, null
|
||||
// sans événement ; mesure : le dernier calcul abouti, null avant lui ;
|
||||
// execution : celle en cours, null hors génération ; enCours vaut vrai du
|
||||
// lancement à la fin de l'enregistrement.
|
||||
let graine = 1;
|
||||
let base = null;
|
||||
let mesure = null;
|
||||
let execution = null;
|
||||
let observable = { enCours: false, fait: 0, total: 0, annulee: null };
|
||||
const abonnes = new Set();
|
||||
|
||||
function publier(changements) {
|
||||
observable = { ...observable, ...changements };
|
||||
for (const fn of [...abonnes]) fn(observable);
|
||||
}
|
||||
|
||||
// Une ouverture : l'événement passe de null, ou d'une autre base, à une
|
||||
// base ; la graine proposée repart de 1.
|
||||
seance.subscribe((etat) => {
|
||||
const suivante = etat.evenement?.base ?? null;
|
||||
if (suivante !== null && suivante !== base) graine = 1;
|
||||
base = suivante;
|
||||
});
|
||||
|
||||
// Les réglages de génération de la charge ouverte, ceux d'une charge
|
||||
// neuve sans événement.
|
||||
const reglagesDeLaCharge = () => seance.etat().charge?.reglages.generation ?? GENERATION_PAR_DEFAUT;
|
||||
|
||||
function annulerAvec(cause) {
|
||||
if (execution === null) return;
|
||||
const courante = execution;
|
||||
publier({ annulee: cause });
|
||||
courante.annuler();
|
||||
}
|
||||
|
||||
function estimer({ nombre, arret }) {
|
||||
const mouvements = nombre * arret;
|
||||
if (mesure === null) return { inconnue: true };
|
||||
const secondes = (mouvements * mesure.dureeMs) / (mesure.mouvements * 1000);
|
||||
return {
|
||||
secondes,
|
||||
longue: secondes > SEUIL_GENERATION_LONGUE_S,
|
||||
auDelaDuPlafond: secondes > PLAFOND_USAGE_S,
|
||||
mesure: { ...mesure },
|
||||
};
|
||||
}
|
||||
|
||||
// Longue : au-delà du seuil estimé, ou, sans mesure, au-delà de dix fois
|
||||
// le réglage par défaut.
|
||||
function estLongue(reglages, estimation) {
|
||||
if (estimation.inconnue === true) return reglages.nombre * reglages.arret > MOUVEMENTS_SANS_MESURE;
|
||||
return estimation.longue;
|
||||
}
|
||||
|
||||
// Les refus d'avant lancement : les réglages d'abord, puis l'ordre du
|
||||
// contrat ; rend la configuration et son instance quand la génération peut
|
||||
// partir.
|
||||
function exigerLancement(reglages, confirmee) {
|
||||
exigerReglages(reglages);
|
||||
const etat = seance.etat();
|
||||
if (etat.evenement === null) throw new ErreurGeneration('AUCUN_EVENEMENT', { remede: null });
|
||||
if (etat.refus !== null) throw new ErreurGeneration(etat.refus.code, etat.refus.details);
|
||||
if (execution !== null || observable.enCours) throw new ErreurGeneration('GENERATION_EN_COURS', { remede: null });
|
||||
const { charge } = etat;
|
||||
if (!charge.participants.some(({ exclu }) => !exclu)) {
|
||||
throw new ErreurGeneration('AUCUN_PARTICIPANT', { remede: versSection('participants') });
|
||||
}
|
||||
if (charge.tables.length === 0) throw new ErreurGeneration('AUCUNE_TABLE', { remede: versSection('tables') });
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
let instance;
|
||||
try {
|
||||
instance = normaliser(configuration);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurConfiguration) throw new ErreurGeneration(erreur.code, { ...erreur.details, remede: null });
|
||||
throw erreur;
|
||||
}
|
||||
const placesManquantes = nombrePlacesManquantes(instance);
|
||||
if (placesManquantes > 0) {
|
||||
throw new ErreurGeneration('PLACES_MANQUANTES', { placesManquantes, remede: versSection('tables') });
|
||||
}
|
||||
const estimation = estimer(reglages);
|
||||
if (!confirmee && estLongue(reglages, estimation)) {
|
||||
throw new ErreurGeneration('CONFIRMATION_REQUISE', { estimation, remede: null });
|
||||
}
|
||||
return { configuration, instance };
|
||||
}
|
||||
|
||||
// Le calcul confié à l'exécuteur : ses avancements publiés, son annulation
|
||||
// par la séance suivie ; rend les propositions et la durée du calcul. Une
|
||||
// annulation lève ErreurAnnulee, même quand l'exécuteur a rendu un
|
||||
// résultat entre-temps.
|
||||
async function calculer(configuration, reglages) {
|
||||
const debut = horloge.instant();
|
||||
execution = executeur.lancer(configuration, reglages);
|
||||
const desabonner = execution.surAvancement((fait, total) => publier({ fait, total }));
|
||||
const desannuler = seance.surAnnulation(() => annulerAvec('seance'));
|
||||
try {
|
||||
const propositions = await execution.resultat;
|
||||
if (observable.annulee !== null) throw new ErreurAnnulee();
|
||||
return { propositions, dureeMs: horloge.instant() - debut };
|
||||
} finally {
|
||||
desabonner();
|
||||
desannuler();
|
||||
execution = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Les propositions à enregistrer, par identifiant du moteur croissant :
|
||||
// chacune sans violation des invariants, sinon GENERATION_INVARIANT_VIOLE,
|
||||
// rang compris — la place de la proposition dans la suite des graines du
|
||||
// lancement, qui n'est l'identifiant d'aucune proposition enregistrée ; puis sans
|
||||
// celles dont le plan est celui d'une proposition présente sans dérive, ou
|
||||
// d'une proposition déjà gardée de ce lancement.
|
||||
function trier(propositions, configuration, instance) {
|
||||
const ordonnees = [...propositions].sort((a, b) => a.id - b.id);
|
||||
for (const proposition of ordonnees) {
|
||||
const violations = verifierInvariants(instance, proposition.plan);
|
||||
if (violations.length > 0) {
|
||||
throw new ErreurGeneration('GENERATION_INVARIANT_VIOLE', { rang: proposition.id, violations, remede: null });
|
||||
}
|
||||
}
|
||||
const charge = seance.etat().charge;
|
||||
const presents = new Set(
|
||||
charge.propositions.filter((p) => derive(p, charge).length === 0).map((p) => JSON.stringify(planDepuisFichier(p))),
|
||||
);
|
||||
const gardees = [];
|
||||
for (const proposition of ordonnees) {
|
||||
const plan = planRange(proposition, configuration, produitVersion);
|
||||
if (presents.has(plan)) continue;
|
||||
presents.add(plan);
|
||||
gardees.push(proposition);
|
||||
}
|
||||
return { gardees, identiques: ordonnees.length - gardees.length };
|
||||
}
|
||||
|
||||
// Les identifiants que l'enregistrement a donnés : ceux des propositions
|
||||
// de la charge qui n'étaient pas avant le geste, et qui portent la graine,
|
||||
// l'arrêt et l'historique d'une proposition gardée ; croissants.
|
||||
function ajouteesDans(charge, avant, gardees) {
|
||||
const cles = new Set(gardees.map(({ graine: g, arret, historique }) => `${g}|${arret}|${historique}`));
|
||||
return charge.propositions
|
||||
.filter(({ id, graine: g, arret, historique }) => !avant.has(id) && cles.has(`${g}|${arret}|${historique}`))
|
||||
.map(({ id }) => id)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
async function generer(reglages, { confirmee = false } = {}) {
|
||||
const { configuration, instance } = exigerLancement(reglages, confirmee);
|
||||
graine = (reglages.graine + 1) % GRAINES;
|
||||
publier({ enCours: true, fait: 0, total: reglages.nombre * reglages.arret, annulee: null });
|
||||
seance.debuterCalcul();
|
||||
try {
|
||||
const { propositions, dureeMs } = await calculer(configuration, reglages);
|
||||
mesure = {
|
||||
dureeMs,
|
||||
mouvements: reglages.nombre * reglages.arret,
|
||||
participants: instance.N,
|
||||
nombre: reglages.nombre,
|
||||
arret: reglages.arret,
|
||||
};
|
||||
const { gardees, identiques } = trier(propositions, configuration, instance);
|
||||
if (gardees.length === 0) return { ajoutees: [], identiques, dureeMs };
|
||||
const avant = new Set(seance.etat().charge.propositions.map(({ id }) => id));
|
||||
await seance.executer('enregistrerGeneration', { propositions: gardees, configuration, produitVersion });
|
||||
return { ajoutees: ajouteesDans(seance.etat().charge, avant, gardees), identiques, dureeMs };
|
||||
} finally {
|
||||
seance.finirCalcul();
|
||||
publier({ enCours: false });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** La graine proposée : 1 à chaque ouverture, puis la dernière lancée plus un, modulo 2^32. */
|
||||
grainePropose: () => graine,
|
||||
/** { graine, nombre, arret, historique } : la graine proposée, les trois autres de la charge. */
|
||||
reglagesProposes() {
|
||||
const { nombre, arret, historique } = reglagesDeLaCharge();
|
||||
return { graine, nombre, arret, historique };
|
||||
},
|
||||
/**
|
||||
* Les saisies des quatre champs, chacune lue par lireEntier (NON_ENTIER
|
||||
* {saisie}), puis bornée à son domaine (HORS_DOMAINE {min, max}, max null
|
||||
* sans borne) ; un historique non saisi — absent, null ou blanc — vaut
|
||||
* celui de la charge. Rend { reglages, refus } : reglages null dès
|
||||
* qu'un champ est refusé, refus dans l'ordre des champs.
|
||||
*/
|
||||
valider(saisies) {
|
||||
const reglages = {};
|
||||
const refus = [];
|
||||
for (const [champ, min, max] of CHAMPS) {
|
||||
const saisie = saisies[champ];
|
||||
if (champ === 'historique' && (saisie === undefined || saisie === null || (typeof saisie === 'string' && saisie.trim() === ''))) {
|
||||
reglages.historique = reglagesDeLaCharge().historique;
|
||||
continue;
|
||||
}
|
||||
const lu = lireEntier(saisie);
|
||||
if (lu.refus !== undefined) {
|
||||
refus.push({ champ, code: lu.refus.code, details: lu.refus.details });
|
||||
} else if (lu.valeur < min || (max !== null && lu.valeur > max)) {
|
||||
refus.push({ champ, code: 'HORS_DOMAINE', details: { min, max, remede: null } });
|
||||
} else {
|
||||
reglages[champ] = lu.valeur;
|
||||
}
|
||||
}
|
||||
return { reglages: refus.length === 0 ? reglages : null, refus };
|
||||
},
|
||||
/**
|
||||
* L'estimation des réglages, proportionnelle aux mouvements de la
|
||||
* dernière mesure : { inconnue: true } sans elle, sinon { secondes,
|
||||
* longue, auDelaDuPlafond, mesure }.
|
||||
*/
|
||||
estimer,
|
||||
/** La mesure du dernier calcul abouti de ce contrôleur, en mémoire ; null avant lui. */
|
||||
derniereGeneration: () => (mesure === null ? null : { ...mesure }),
|
||||
/**
|
||||
* Lance une génération et l'enregistre : { ajoutees, identiques, dureeMs }.
|
||||
* Refuse avant tout lancement, par ErreurGeneration, dans cet ordre :
|
||||
* REGLAGE_HORS_DOMAINE {reglage} ; AUCUN_EVENEMENT ; le refus courant de
|
||||
* la séance ; GENERATION_EN_COURS ;
|
||||
* AUCUN_PARTICIPANT ; AUCUNE_TABLE ; le refus de normaliser ;
|
||||
* PLACES_MANQUANTES ; CONFIRMATION_REQUISE {estimation}, que
|
||||
* confirmee lève. Après la recherche : GENERATION_INVARIANT_VIOLE
|
||||
* {rang, violations}, puis ce
|
||||
* qu'executer lève, tel quel. Annulée : ErreurAnnulee.
|
||||
*/
|
||||
generer,
|
||||
/** Annule la génération en cours, sans résultat partiel ; annulee vaut 'commande'. */
|
||||
annuler: () => annulerAvec('commande'),
|
||||
/**
|
||||
* Le contrat des magasins de Svelte sur { enCours, fait, total, annulee } :
|
||||
* annulee, la cause de la dernière annulation, 'commande' ou 'seance',
|
||||
* null dès le lancement suivant.
|
||||
*/
|
||||
subscribe(fn) {
|
||||
abonnes.add(fn);
|
||||
fn(observable);
|
||||
return () => {
|
||||
abonnes.delete(fn);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
1062
src/application/generation.test.js
Normal file
1062
src/application/generation.test.js
Normal file
File diff suppressed because it is too large
Load diff
57
src/application/generation_travailleur.js
Normal file
57
src/application/generation_travailleur.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le côté travailleur de la génération (§ 5.10) : la recherche du moteur
|
||||
// hors du fil de l'interface. Le travailleur reçoit un message, calcule, et
|
||||
// poste ses avancements puis son résultat ou son erreur :
|
||||
//
|
||||
// → { type: 'lancer', configuration, reglages }
|
||||
// ← { type: 'avancement', fait, total } au plus une fois toutes les
|
||||
// CADENCE_AVANCEMENT progressions
|
||||
// du moteur, fait croissant
|
||||
// ← { type: 'resultat', propositions } celles de rechercher, telles quelles
|
||||
// ← { type: 'erreur', code, details } REGLAGE_HORS_DOMAINE {reglage}
|
||||
// d'exigerReglages, avant tout
|
||||
// calcul ; puis, par
|
||||
// versErreurGeneration, le code et
|
||||
// les détails d'une
|
||||
// ErreurConfiguration
|
||||
//
|
||||
// L'annulation est terminate() du côté de l'interface : immédiate, sans
|
||||
// résultat partiel ; le travailleur ne lit aucun signal. Un message d'un
|
||||
// autre type ne lance rien. Une erreur que versErreurGeneration ne convertit
|
||||
// pas est relevée, et le travailleur la signale par son propre canal
|
||||
// d'erreur.
|
||||
import { rechercher } from '../moteur/recherche.js';
|
||||
import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js';
|
||||
|
||||
/** Progressions du moteur — une toutes les 1 000 itérations — entre deux messages d'avancement. */
|
||||
export const CADENCE_AVANCEMENT = 50;
|
||||
|
||||
/**
|
||||
* Branche une portée de travailleur — { onmessage, postMessage } — sur
|
||||
* rechercher, selon les messages de l'en-tête du module.
|
||||
*
|
||||
* @param {{onmessage: ((evenement: {data: unknown}) => void)|null, postMessage: (message: Object) => void}} portee
|
||||
*/
|
||||
export function brancherTravailleur(portee) {
|
||||
portee.onmessage = ({ data }) => {
|
||||
if (data === null || typeof data !== 'object' || data.type !== 'lancer') return;
|
||||
let progressions = 0;
|
||||
const progression = (fait, total) => {
|
||||
progressions += 1;
|
||||
if (progressions % CADENCE_AVANCEMENT === 0) portee.postMessage({ type: 'avancement', fait, total });
|
||||
};
|
||||
let propositions;
|
||||
try {
|
||||
exigerReglages(data.reglages);
|
||||
propositions = rechercher(data.configuration, data.reglages, { progression });
|
||||
} catch (erreur) {
|
||||
const convertie = versErreurGeneration(erreur);
|
||||
if (!(convertie instanceof ErreurGeneration)) throw convertie;
|
||||
portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details });
|
||||
return;
|
||||
}
|
||||
portee.postMessage({ type: 'resultat', propositions });
|
||||
};
|
||||
}
|
||||
66
src/application/generation_travailleur.test.js
Normal file
66
src/application/generation_travailleur.test.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du travailleur de génération (§ 5.10) : brancherTravailleur sur
|
||||
// une portée factice, qui garde chaque message posté. Les configurations
|
||||
// viennent des démonstrations livrées et d'une charge aux noms inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { texteLivre } from '../demo/livrees.js';
|
||||
import { rechercher } from '../moteur/recherche.js';
|
||||
import { analyser, configurationDepuisCharge } from '../stockage/document.js';
|
||||
import { CADENCE_AVANCEMENT, brancherTravailleur } from './generation_travailleur.js';
|
||||
|
||||
const configurationDe = (cle) => configurationDepuisCharge(analyser(texteLivre(cle)).charge);
|
||||
|
||||
// Une portée de travailleur factice : ce que le travailleur poste, et
|
||||
// l'envoi d'un message au travailleur.
|
||||
function porteeFactice() {
|
||||
const postes = [];
|
||||
const portee = {
|
||||
onmessage: null,
|
||||
postMessage: (message) => postes.push(structuredClone(message)),
|
||||
};
|
||||
brancherTravailleur(portee);
|
||||
return { postes, envoyer: (data) => portee.onmessage({ data }) };
|
||||
}
|
||||
|
||||
describe('travailleur de génération (§ 5.10)', () => {
|
||||
test("après lancer, des avancements à fait croissant, au plus un tous les CADENCE_AVANCEMENT, puis le résultat de rechercher", () => {
|
||||
assert.equal(CADENCE_AVANCEMENT, 50);
|
||||
const configuration = configurationDe('petite-conflit');
|
||||
const reglages = { graine: 9, nombre: 2, arret: 60000, historique: 1000 };
|
||||
const { postes, envoyer } = porteeFactice();
|
||||
envoyer({ type: 'lancer', configuration, reglages });
|
||||
const avancements = postes.filter(({ type }) => type === 'avancement');
|
||||
assert.ok(avancements.length > 0, 'aucun avancement');
|
||||
assert.ok(avancements.length <= Math.ceil((reglages.nombre * reglages.arret) / 1000 / CADENCE_AVANCEMENT));
|
||||
assert.deepEqual(avancements, [
|
||||
{ type: 'avancement', fait: 50000, total: 120000 },
|
||||
{ type: 'avancement', fait: 100000, total: 120000 },
|
||||
]);
|
||||
assert.deepEqual(postes.at(-1), { type: 'resultat', propositions: rechercher(configuration, reglages) });
|
||||
assert.equal(postes.length, avancements.length + 1);
|
||||
});
|
||||
|
||||
test('des places manquantes postent erreur PLACES_MANQUANTES, les détails du moteur', () => {
|
||||
const configuration = configurationDe('petite');
|
||||
configuration.tables = configuration.tables.slice(1);
|
||||
const { postes, envoyer } = porteeFactice();
|
||||
envoyer({ type: 'lancer', configuration, reglages: { graine: 1, nombre: 1, arret: 10 } });
|
||||
assert.deepEqual(postes, [{ type: 'erreur', code: 'PLACES_MANQUANTES', details: { placesManquantes: 3 } }]);
|
||||
});
|
||||
|
||||
test('une graine de 2^32 poste erreur REGLAGE_HORS_DOMAINE, qui nomme le réglage, sans le message du moteur', () => {
|
||||
const { postes, envoyer } = porteeFactice();
|
||||
envoyer({ type: 'lancer', configuration: configurationDe('petite'), reglages: { graine: 2 ** 32, nombre: 1, arret: 10 } });
|
||||
assert.deepEqual(postes, [{ type: 'erreur', code: 'REGLAGE_HORS_DOMAINE', details: { reglage: 'graine', remede: null } }]);
|
||||
});
|
||||
|
||||
test("un message d'un autre type ne lance rien", () => {
|
||||
const { postes, envoyer } = porteeFactice();
|
||||
envoyer({ type: 'inconnu' });
|
||||
envoyer(null);
|
||||
assert.deepEqual(postes, []);
|
||||
});
|
||||
});
|
||||
|
|
@ -16,11 +16,22 @@
|
|||
// La table se partage en fichiers, un par domaine, sous libelles/ : chacun
|
||||
// exporte TABLE, une Map, et libelles.js les assemble par ordre alphabétique
|
||||
// des fichiers ; les tables, comme l'assemblage, sont hors du balayage.
|
||||
//
|
||||
// src/moteur se relève à part, épreuves exclues : chaque littéral en
|
||||
// capitales de ses sources, hors commentaires, y est un code — celui d'une
|
||||
// ErreurConfiguration ou d'une violation de ses vérificateurs —, que le
|
||||
// moteur lève par une constante ou par une expression autant que par un
|
||||
// appel. Une seule exception, nommée : les valeurs de domine dans manque.js,
|
||||
// qui disent laquelle de deux propositions sert le mieux et ne s'affichent
|
||||
// pas comme un code.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CRITERES } from '../moteur/classement.js';
|
||||
import { TABLES_INTERFACE } from '../interface/libelles.js';
|
||||
import { creerTraduction } from './traduction.js';
|
||||
import { CODES, TABLES_APPLICATION, libelle } from './libelles.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
|
||||
|
|
@ -90,8 +101,40 @@ function modulesDe(arbre) {
|
|||
.sort();
|
||||
}
|
||||
|
||||
// --- Le relevé du moteur -----------------------------------------------------
|
||||
|
||||
const MOTEUR = 'src/moteur';
|
||||
// Les littéraux en capitales du moteur qui ne sont pas des codes, par module.
|
||||
const PAS_DES_CODES = new Map([[join(MOTEUR, 'manque.js'), new Set(['A', 'B'])]]);
|
||||
// Les modules du moteur qui portent des codes.
|
||||
const MODULES_A_CODES = ['configuration.js', 'plafond.js', 'recherche.js', 'verification.js'].map((nom) => join(MOTEUR, nom));
|
||||
const LITTERAL_CODE = new RegExp(CODE, 'g');
|
||||
|
||||
/** Chaque littéral en capitales d'un texte source, hors commentaires. */
|
||||
function litterauxEnCapitales(texte) {
|
||||
return [...sansCommentaires(texte).matchAll(LITTERAL_CODE)].map((trouve) => trouve[1]);
|
||||
}
|
||||
|
||||
// Relevé du moteur : chaque code de ses modules, hors exceptions nommées,
|
||||
// le module qui émet chacun en premier, et les modules qui en portent.
|
||||
function releverMoteur() {
|
||||
const codes = new Set();
|
||||
const emetteurs = new Map();
|
||||
const porteurs = [];
|
||||
for (const chemin of modulesDe(MOTEUR)) {
|
||||
const exclus = PAS_DES_CODES.get(chemin) ?? new Set();
|
||||
const trouves = litterauxEnCapitales(readFileSync(join(RACINE, chemin), 'utf8')).filter((code) => !exclus.has(code));
|
||||
if (trouves.length > 0) porteurs.push(chemin);
|
||||
for (const code of trouves) {
|
||||
codes.add(code);
|
||||
if (!emetteurs.has(code)) emetteurs.set(code, chemin);
|
||||
}
|
||||
}
|
||||
return { codes, emetteurs, porteurs };
|
||||
}
|
||||
|
||||
// Relevé de l'arbre réel : par arbre, les codes émis et le fichier qui émet
|
||||
// chacun en premier ; puis ceux des tables de contrat.
|
||||
// chacun en premier ; puis ceux des tables de contrat ; puis ceux du moteur.
|
||||
function releverArbre() {
|
||||
const parArbre = new Map();
|
||||
const emetteurs = new Map();
|
||||
|
|
@ -107,9 +150,15 @@ function releverArbre() {
|
|||
}
|
||||
const tables = new Set(TABLES.flatMap((chemin) => codesDesTables(readFileSync(join(RACINE, chemin), 'utf8'))));
|
||||
for (const code of tables) if (!emetteurs.has(code)) emetteurs.set(code, 'table de contrat');
|
||||
const moteur = releverMoteur();
|
||||
for (const [code, chemin] of moteur.emetteurs) if (!emetteurs.has(code)) emetteurs.set(code, chemin);
|
||||
parArbre.set(MOTEUR, moteur.codes);
|
||||
return { parArbre, tables, emetteurs };
|
||||
}
|
||||
|
||||
// Tous les codes relevés : tables de contrat, arbres balayés et moteur.
|
||||
const tousReleves = ({ parArbre, tables }) => new Set([...tables, ...[...parArbre.values()].flatMap((codes) => [...codes])]);
|
||||
|
||||
describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
|
||||
test("le relevé lit le premier argument d'un appel, une clé code, un code de détail, et rien d'un commentaire", () => {
|
||||
const source = [
|
||||
|
|
@ -182,9 +231,10 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
|
|||
assert.ok(tables.has('VERROU_PRIS') && tables.has('IDENTIFIANT_HORS_COMPTEUR'));
|
||||
});
|
||||
|
||||
test('chaque code que src/stockage, src/csv et src/application émettent a son texte', () => {
|
||||
const { parArbre, tables, emetteurs } = releverArbre();
|
||||
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
|
||||
test('chaque code que src/stockage, src/csv, src/application et src/moteur émettent a son texte', () => {
|
||||
const releve = releverArbre();
|
||||
const { emetteurs } = releve;
|
||||
const releves = tousReleves(releve);
|
||||
assert.ok(releves.size > 0, 'balayage vide');
|
||||
const connus = new Set(CODES);
|
||||
const sansTexte = [...releves].filter((code) => !connus.has(code)).sort();
|
||||
|
|
@ -196,8 +246,7 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
|
|||
});
|
||||
|
||||
test('chaque texte de la table a un émetteur : aucune entrée morte', () => {
|
||||
const { parArbre, tables } = releverArbre();
|
||||
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
|
||||
const releves = tousReleves(releverArbre());
|
||||
assert.ok(CODES.length > 0, 'table vide');
|
||||
assert.deepEqual(CODES.filter((code) => !releves.has(code)), [], 'textes sans émetteur');
|
||||
});
|
||||
|
|
@ -334,6 +383,61 @@ const EXEMPLES = new Map([
|
|||
// Moteur
|
||||
['PLACES_MANQUANTES', { placesManquantes: 3 }],
|
||||
['SURRESERVATION', { table: 2, tour: 1, reservees: 9, capacite: 8, remede: null }],
|
||||
['CAPACITE', { table: 2, capacite: 1 }],
|
||||
['TOURS', { tours: 0 }],
|
||||
['PARTICIPANT_DOUBLON', { participant: 4 }],
|
||||
['TABLE_DOUBLON', { table: 3 }],
|
||||
['RESERVATION_INCONNUE', { reservation: 0, participant: 42 }],
|
||||
['RESERVATION_TOUR', { reservation: 2, tour: 5 }],
|
||||
['RESERVATION_CONFLIT', { participant: 4, tour: 2, tables: [1, 3] }],
|
||||
['ENUMERATION_TROP_GRANDE', { participant: 7, tablesAdmissibles: 40, toursLibres: 9, limite: 1000000 }],
|
||||
['PLAN_TOURS', { attendu: 4, tours: 3, reserves: 3 }],
|
||||
['PLAN_TABLE_INCONNUE', { table: 9 }],
|
||||
['PLAN_TABLE_DOUBLON', { table: 2 }],
|
||||
['PLAN_TABLE_ABSENTE', { table: 5 }],
|
||||
['PLAN_LISTES', { tour: 2, listes: 3, tables: 4 }],
|
||||
['PLAN_INCONNU', { participant: 42, tour: 1 }],
|
||||
['PLAN_EXCLU_PLACE', { participant: 3, tour: 2 }],
|
||||
['PLAN_DOUBLE_PLACE', { participant: 5, tour: 3 }],
|
||||
['PLAN_NON_ASSIS', { participant: 6, tour: 4 }],
|
||||
['TABLE_INCONNUE', { table: 9, detail: 'inconnue' }],
|
||||
['DOUBLE_PLACE', { participant: 5, tour: 3 }],
|
||||
['EXCLU_PLACE', { participant: 3, tour: 2 }],
|
||||
['NON_ASSIS', { participant: 6, tour: 4 }],
|
||||
['RESERVATION', { participant: 4, table: 5, tour: 1 }],
|
||||
['PLAFOND_A_PRIORI', { index: 2, participant: 3 }],
|
||||
['ORDRE_PLAFONDS', { index: 2, participant: 3 }],
|
||||
['PLAFOND_REALISE', { index: 0, participant: 1 }],
|
||||
['DEPASSEMENT', { index: 4, participant: 9 }],
|
||||
// Génération
|
||||
['GENERATION_EN_COURS', { remede: null }],
|
||||
['AUCUN_PARTICIPANT', { remede: { geste: 'ouvrirSection', section: 'participants' } }],
|
||||
['AUCUNE_TABLE', { remede: { geste: 'ouvrirSection', section: 'tables' } }],
|
||||
[
|
||||
'CONFIRMATION_REQUISE',
|
||||
{
|
||||
estimation: {
|
||||
secondes: 1200,
|
||||
longue: true,
|
||||
auDelaDuPlafond: false,
|
||||
mesure: { dureeMs: 2000, mouvements: 1000000, participants: 260, nombre: 5, arret: 200000 },
|
||||
},
|
||||
remede: null,
|
||||
},
|
||||
],
|
||||
['HORS_DOMAINE', { min: 1, max: null, remede: null }],
|
||||
['REGLAGE_HORS_DOMAINE', { reglage: 'arret', remede: null }],
|
||||
[
|
||||
'GENERATION_INVARIANT_VIOLE',
|
||||
{
|
||||
rang: 2,
|
||||
violations: [
|
||||
{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 },
|
||||
{ code: 'CAPACITE', table: 5, tour: 2 },
|
||||
],
|
||||
remede: null,
|
||||
},
|
||||
],
|
||||
// Refus et avertissements des commandes
|
||||
['NOM_REQUIS', { remede: null }],
|
||||
['DATE_INVALIDE', { date: '2031-02-30', remede: null }],
|
||||
|
|
@ -399,6 +503,35 @@ const EXEMPLES = new Map([
|
|||
['ENTREE_DEPLACER_DANS_RETENU', { personne: 'Iris Ombrelle', de: 4, vers: 7, siege: null, tour: 2 }],
|
||||
['ENTREE_ECHANGER_DANS_RETENU', { a: 'Iris Ombrelle', deA: 4, b: 'Théo Pervenche', deB: null, tour: 2 }],
|
||||
['ENTREE_RETIRER_DU_TOUR', { personne: 'Iris Ombrelle', numero: 4, tour: 2 }],
|
||||
// Mobilier : saisies, capacité, sièges retirés, libellés figés
|
||||
['NON_ENTIER', { saisie: '8abc', remede: null }],
|
||||
['MOINS_DE_DEUX', { remede: null }],
|
||||
['MOINS_D_UN_TOUR', { tours: 0, remede: null }],
|
||||
['SOUS_RESERVATIONS', { table: 2, n: 7, tour: 1, remede: { geste: 'afficherTour', tour: 1, proposition: null } }],
|
||||
['SOUS_OCCUPES', { table: 2, n: 7, tour: 3, remede: { geste: 'afficherTour', tour: 3, proposition: null } }],
|
||||
[
|
||||
'SIEGES_RETIRES',
|
||||
{
|
||||
table: 2,
|
||||
sieges: [
|
||||
{ siege: 8, cause: 'SIEGE_TITRE', libelle: 'animateur' },
|
||||
{ siege: 7, cause: 'SIEGE_RESERVE', participant: 1, suspendue: true },
|
||||
],
|
||||
remede: { commande: 'retirerTitre', arguments: { table: 2, siege: 8 } },
|
||||
},
|
||||
],
|
||||
['SIEGE_RESERVE', { siege: 7, participant: 1, suspendue: false }],
|
||||
['SIEGE_TITRE', { siege: 8, libelle: 'animateur' }],
|
||||
['SURNUMERAIRES', { table: 3, tour: 2, personnes: [16, 17] }],
|
||||
['AU_DELA_LISIBLE', { seuil: 16 }],
|
||||
['ENTREE_CREER_TABLE', { numero: 3 }],
|
||||
['ENTREE_SUPPRIMER_TABLE', { numero: 2, reservations: 2, titres: 1 }],
|
||||
['ENTREE_DEPLACER_TABLE', { numero: 3 }],
|
||||
['ENTREE_CHANGER_PLACES_TABLE', { numero: 5, sieges: 10 }],
|
||||
['ENTREE_REALIGNER_TABLE', { numero: 5, sieges: 8 }],
|
||||
['ENTREE_CHANGER_DEFAUT', { sieges: 10, realignees: 4 }],
|
||||
['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }],
|
||||
['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }],
|
||||
]);
|
||||
|
||||
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni
|
||||
|
|
@ -668,3 +801,142 @@ describe('libellés : les tables par domaine (§ 14.6)', () => {
|
|||
assert.deepEqual([...CODES], [...toutes].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||
});
|
||||
});
|
||||
|
||||
// --- Le moteur et la génération (A6) -------------------------------------------
|
||||
|
||||
describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7)', () => {
|
||||
test('le relevé du moteur porte des codes, dans ses quatre modules à codes, et rien des valeurs de domine', () => {
|
||||
const { codes, porteurs } = releverMoteur();
|
||||
assert.ok(codes.size >= 20, `${codes.size} codes relevés`);
|
||||
assert.deepEqual(porteurs, MODULES_A_CODES);
|
||||
assert.ok(!codes.has('A') && !codes.has('B'));
|
||||
for (const code of ['PLAN_EXCLU_PLACE', 'PLAN_INCONNU', 'ENUMERATION_TROP_GRANDE', 'PLACES_MANQUANTES', 'RESERVATION']) {
|
||||
assert.ok(codes.has(code), code);
|
||||
}
|
||||
assert.deepEqual(litterauxEnCapitales("// 'DANS_UN_COMMENTAIRE'\nconst x = a ? 'PREMIER' : 'SECOND';"), ['PREMIER', 'SECOND']);
|
||||
});
|
||||
|
||||
test("chaque code du moteur a sa clé, une seule ; un code que seul le moteur lève l'a dans libelles/moteur.js, aux exceptions nommées près", async () => {
|
||||
const { codes } = releverMoteur();
|
||||
const releve = releverArbre();
|
||||
const ailleurs = new Set([...releve.tables, ...ARBRES.flatMap((arbre) => [...releve.parArbre.get(arbre)])]);
|
||||
const { TABLE: MOTEUR_TABLE } = await import('./libelles/moteur.js');
|
||||
const { TABLE: STOCKAGE_TABLE } = await import('./libelles/stockage.js');
|
||||
// INCONNU est déjà la clé de la table d'A1, une faute de placement du
|
||||
// stockage aux mêmes détails ; PLACES_MANQUANTES et SURRESERVATION sont
|
||||
// dans libelles/moteur.js depuis la séance (A2), et ne s'y redéfinissent pas.
|
||||
const EXCEPTIONS = new Map([['INCONNU', STOCKAGE_TABLE]]);
|
||||
let comptes = 0;
|
||||
for (const code of codes) {
|
||||
comptes += 1;
|
||||
const porteuses = TABLES_APPLICATION.filter((table) => table.has(code));
|
||||
assert.equal(porteuses.length, 1, `${code} : ${porteuses.length} tables`);
|
||||
if (EXCEPTIONS.has(code)) assert.equal(porteuses[0], EXCEPTIONS.get(code), code);
|
||||
else if (!ailleurs.has(code)) assert.equal(porteuses[0], MOTEUR_TABLE, `${code} hors de libelles/moteur.js`);
|
||||
}
|
||||
assert.ok(comptes > 0);
|
||||
for (const code of ['PLACES_MANQUANTES', 'SURRESERVATION']) assert.ok(MOTEUR_TABLE.has(code), code);
|
||||
});
|
||||
|
||||
test('chaque critère de CRITERES a son libellé moteur.critere.<nom>', () => {
|
||||
assert.ok(CRITERES.length > 0);
|
||||
for (const critere of CRITERES) {
|
||||
const texte = libelle(`moteur.critere.${critere}`);
|
||||
assert.ok(texte.length > 0, critere);
|
||||
for (const trace of TRACES) assert.doesNotMatch(texte, trace);
|
||||
}
|
||||
assert.equal(new Set(CRITERES.map((critere) => libelle(`moteur.critere.${critere}`))).size, CRITERES.length);
|
||||
});
|
||||
|
||||
test("les tables de l'application et des écrans s'assemblent sans clé en double", () => {
|
||||
assert.ok(TABLES_INTERFACE.length > 0);
|
||||
assert.doesNotThrow(() => creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]));
|
||||
});
|
||||
|
||||
test('CAPACITE et TOURS, levés sous deux formes, ont un seul texte qui les distingue par leurs détails', () => {
|
||||
const configuration = libelle('CAPACITE', { table: 2, capacite: 1 });
|
||||
const violation = libelle('CAPACITE', { table: 2, tour: 3 });
|
||||
assert.notEqual(configuration, violation);
|
||||
assert.match(configuration, /\b1\b/);
|
||||
assert.match(violation, /tour 3/);
|
||||
const tours = libelle('TOURS', { tours: 0 });
|
||||
const toursViolation = libelle('TOURS', {});
|
||||
assert.notEqual(tours, toursViolation);
|
||||
for (const texte of [configuration, violation, tours, toursViolation]) for (const trace of TRACES) assert.doesNotMatch(texte, trace, texte);
|
||||
});
|
||||
|
||||
test("une violation d'indicateur nomme la personne quand l'appelant la traduit, son rang sinon ; un détail de table inconnu lève", () => {
|
||||
assert.match(libelle('DEPASSEMENT', { index: 4, participant: 9 }), /n° 9/);
|
||||
assert.match(libelle('DEPASSEMENT', { index: 4 }), /rang 5/);
|
||||
for (const detail of ['inconnue', 'doublon', 'absente']) assert.match(libelle('TABLE_INCONNUE', { table: 9, detail }), /9/);
|
||||
assert.match(libelle('TABLE_INCONNUE', { tour: 2, detail: 'listes' }), /tour 2/);
|
||||
assert.throws(() => libelle('TABLE_INCONNUE', { table: 9, detail: 'inventee' }), RangeError);
|
||||
});
|
||||
|
||||
test('chaque refus de valider et de generer a son texte, dans la table de son domaine', async () => {
|
||||
const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE]));
|
||||
// Les refus que lèvent les épreuves de valider et de generer
|
||||
// (generation.test.js), et la table qui porte chacun. Ceux de la séance,
|
||||
// du stockage, du mobilier et du moteur sont relayés tels quels ;
|
||||
// GENERATION_INVARIANT_VIOLE, propre à la génération, se distingue
|
||||
// d'INVARIANT_VIOLE, la violation du retenu, que la séance porte.
|
||||
const REFUS = [
|
||||
['NON_ENTIER', 'mobilier'],
|
||||
['HORS_DOMAINE', 'generation'],
|
||||
['AUCUN_EVENEMENT', 'seance'],
|
||||
['LECTURE_SEULE', 'stockage'],
|
||||
['MODE_LECTURE', 'seance'],
|
||||
['SUPPORT_ROMPU', 'seance'],
|
||||
['GENERATION_EN_COURS', 'generation'],
|
||||
['AUCUN_PARTICIPANT', 'generation'],
|
||||
['AUCUNE_TABLE', 'generation'],
|
||||
['SURRESERVATION', 'moteur'],
|
||||
['PLACES_MANQUANTES', 'moteur'],
|
||||
['CONFIRMATION_REQUISE', 'generation'],
|
||||
['REGLAGE_HORS_DOMAINE', 'generation'],
|
||||
['GENERATION_INVARIANT_VIOLE', 'generation'],
|
||||
['COMPTEUR_SATURE', 'stockage'],
|
||||
];
|
||||
assert.equal(REFUS.length, 15);
|
||||
for (const [code, table] of REFUS) {
|
||||
assert.ok(parTable.get(table).has(code), `${code} hors de ${table}.js`);
|
||||
assert.ok(libelle(code, EXEMPLES.get(code)).length > 0, code);
|
||||
}
|
||||
});
|
||||
|
||||
test("la confirmation d'une génération longue dit l'ordre de grandeur, la mesure, le plafond ; sans mesure, le seuil", () => {
|
||||
const mesure = { dureeMs: 2000, mouvements: 1000000, participants: 260, nombre: 5, arret: 200000 };
|
||||
const longue = libelle('CONFIRMATION_REQUISE', { estimation: { secondes: 1200, longue: true, auDelaDuPlafond: false, mesure }, remede: null });
|
||||
assert.match(longue, /environ vingt minutes/);
|
||||
assert.match(longue, /260 participants/);
|
||||
assert.doesNotMatch(longue, /trente minutes/);
|
||||
const plafond = libelle('CONFIRMATION_REQUISE', { estimation: { secondes: 7200, longue: true, auDelaDuPlafond: true, mesure }, remede: null });
|
||||
assert.match(plafond, /trente minutes/);
|
||||
const inconnue = libelle('CONFIRMATION_REQUISE', { estimation: { inconnue: true }, remede: null });
|
||||
assert.match(inconnue, /dix fois le réglage par défaut/);
|
||||
for (const texte of [longue, plafond, inconnue]) for (const trace of TRACES) assert.doesNotMatch(texte, trace, texte);
|
||||
});
|
||||
|
||||
test("une violation d'invariant rendue par la génération nomme son rang et chaque règle, sans retenu ni dérive", () => {
|
||||
const details = {
|
||||
rang: 2,
|
||||
violations: [
|
||||
{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 },
|
||||
{ code: 'CAPACITE', table: 5, tour: 2 },
|
||||
],
|
||||
remede: null,
|
||||
};
|
||||
const texte = libelle('GENERATION_INVARIANT_VIOLE', details);
|
||||
assert.match(texte, /rang 2\b/);
|
||||
assert.match(texte, /2 règles/);
|
||||
assert.ok(texte.includes(libelle('RESERVATION', details.violations[0])), texte);
|
||||
assert.ok(texte.includes(libelle('CAPACITE', details.violations[1])), texte);
|
||||
assert.doesNotMatch(texte, /retenu|dérive|proposition n°/);
|
||||
assert.match(libelle('GENERATION_INVARIANT_VIOLE', { ...details, violations: details.violations.slice(0, 1) }), /1 règle\b/);
|
||||
});
|
||||
|
||||
test('un domaine borné nomme ses deux bornes, un domaine ouvert son minimum', () => {
|
||||
assert.match(libelle('HORS_DOMAINE', { min: 0, max: 4294967295, remede: null }), /0 et 4\u{202F}294\u{202F}967\u{202F}295/u);
|
||||
assert.match(libelle('HORS_DOMAINE', { min: 1, max: null, remede: null }), /au moins 1\b/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,39 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les textes des commandes de la génération et des propositions (§ 5.7,
|
||||
// § 14.6) : refus et libellés figés. Cette table ne porte que des codes.
|
||||
import { compte } from './aides.js';
|
||||
// § 14.6) : refus et libellés figés, puis les refus du contrôleur de
|
||||
// génération et de la saisie de ses réglages (§ 5.9, § 5.10, § 15.4). Cette
|
||||
// table ne porte que des codes. Un nombre et une durée se mettent en forme
|
||||
// par creerFormat, sur la composition que la traduction passe à l'entrée.
|
||||
import { creerFormat } from '../format.js';
|
||||
import { cite, compte, phraseDeRaison } from './aides.js';
|
||||
|
||||
// Le nom de chaque réglage de génération, celui que la saisie affiche.
|
||||
const NOMS_DES_REGLAGES = new Map([
|
||||
['graine', 'Graine'],
|
||||
['nombre', 'Propositions par génération'],
|
||||
['arret', 'Mouvements par proposition'],
|
||||
['historique', "Historique d'acceptation"],
|
||||
]);
|
||||
|
||||
// L'estimation d'une génération longue, en mots : l'ordre de grandeur et la
|
||||
// mesure dont il vient, ou le seuil d'une génération sans mesure.
|
||||
function phraseEstimation(estimation, texte) {
|
||||
if (estimation.inconnue === true) {
|
||||
return (
|
||||
"Aucune durée n'est encore mesurée sur ce poste dans cette séance, et ce réglage demande plus de " +
|
||||
'dix fois le réglage par défaut'
|
||||
);
|
||||
}
|
||||
const format = creerFormat(texte);
|
||||
const { mesure } = estimation;
|
||||
const plafond = estimation.auDelaDuPlafond ? ", au-delà du plafond d'usage de trente minutes" : '';
|
||||
return (
|
||||
`Ce réglage prendrait ${format.ordreDeGrandeur(estimation.secondes)}${plafond}, d'après le dernier calcul, ` +
|
||||
`${format.duree(mesure.dureeMs)} pour ${format.nombre(mesure.mouvements)} mouvements ` +
|
||||
`sur ${compte(mesure.participants, 'participant', 'participants')}`
|
||||
);
|
||||
}
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Les refus des propositions (§ 5.7) ------------------------------------
|
||||
|
|
@ -23,4 +54,45 @@ export const TABLE = new Map([
|
|||
`Effacé ${compte(n, 'proposition', 'propositions')}` +
|
||||
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
|
||||
],
|
||||
|
||||
// --- Le contrôleur de génération (§ 5.9, § 5.10, § 15.4) -------------------
|
||||
['GENERATION_EN_COURS', () => "Une génération est déjà en cours : l'annuler, ou attendre qu'elle s'achève."],
|
||||
[
|
||||
'AUCUN_PARTICIPANT',
|
||||
() => "Aucune personne à placer : la liste des participants est vide, ou chacun en est exclu.",
|
||||
],
|
||||
['AUCUNE_TABLE', () => "Aucune table : la génération place les personnes autour des tables de l'événement."],
|
||||
[
|
||||
'CONFIRMATION_REQUISE',
|
||||
({ estimation }, texte) =>
|
||||
`${phraseEstimation(estimation, texte)}. Confirmer « Générer » lance quand même la génération.`,
|
||||
],
|
||||
[
|
||||
'REGLAGE_HORS_DOMAINE',
|
||||
({ reglage }) =>
|
||||
`Le réglage ${cite(NOMS_DES_REGLAGES.get(reglage) ?? String(reglage))} sort de son domaine : ` +
|
||||
"la génération n'a pas eu lieu.",
|
||||
],
|
||||
// rang : la place du placement dans la suite des graines du lancement,
|
||||
// jamais un numéro de proposition, puisque rien n'est enregistré ; chaque
|
||||
// violation se dit par le texte de son code (libelles/moteur.js).
|
||||
[
|
||||
'GENERATION_INVARIANT_VIOLE',
|
||||
({ rang, violations }, texte) =>
|
||||
`Le placement que la génération a rendu au rang ${rang} de ce lancement enfreint ` +
|
||||
`${compte(violations.length, 'règle', 'règles')} ; rien n'est enregistré. ` +
|
||||
violations.map((violation) => phraseDeRaison(violation.code, violation, texte)).join(' '),
|
||||
],
|
||||
|
||||
// --- La saisie des réglages (§ 5.7) -----------------------------------------
|
||||
// max null : le domaine n'a pas de borne supérieure.
|
||||
[
|
||||
'HORS_DOMAINE',
|
||||
({ min, max }, texte) => {
|
||||
const { nombre } = creerFormat(texte);
|
||||
return max === null || max === undefined
|
||||
? `La valeur doit être d'au moins ${nombre(min)}.`
|
||||
: `La valeur doit être comprise entre ${nombre(min)} et ${nombre(max)}.`;
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,114 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les textes des commandes du mobilier — tables, sièges, tours (§ 6, § 14.6).
|
||||
export const TABLE = new Map();
|
||||
// Les textes des commandes du mobilier — tables, sièges, tours, réglages
|
||||
// (§ 4, § 6, § 7.1, § 8.2, § 14.6) : les refus d'une capacité et d'un nombre
|
||||
// de tours, leurs avertissements, les causes d'un siège retiré et les
|
||||
// libellés figés du journal. Les détails désignent tables et personnes par
|
||||
// identifiant : le refus se pose sur la table qu'il concerne, et son texte ne
|
||||
// les nomme pas. Un libellé figé reçoit le numéro de table que la commande
|
||||
// lit dans la charge.
|
||||
import { cite, compte, enumeration, nomDans } from './aides.js';
|
||||
|
||||
// Le nom de chaque réglage que modifierReglage change, tel que le journal le
|
||||
// fige (§ 5.3, § 5.10).
|
||||
const NOMS_DES_REGLAGES = new Map([
|
||||
['separerAppartenances', 'Séparer les appartenances'],
|
||||
['nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
|
||||
['nouvelleTable', 'Nouvelle table à chaque tour'],
|
||||
['varierAppartenances', 'Varier les appartenances rencontrées'],
|
||||
['attribuerSieges', 'Attribuer les sièges'],
|
||||
['generation.nombre', 'Propositions par génération'],
|
||||
['generation.arret', 'Mouvements par proposition'],
|
||||
['generation.historique', "Historique d'acceptation"],
|
||||
]);
|
||||
|
||||
// La valeur d'un réglage dans un libellé : activé ou désactivé pour un
|
||||
// interrupteur, le nombre sinon.
|
||||
const valeurDeReglage = (valeur) => (typeof valeur === 'boolean' ? (valeur ? 'activé' : 'désactivé') : String(valeur));
|
||||
|
||||
// Ce qu'une saisie refusée est : vide, ou un texte qui n'est pas un entier.
|
||||
const saisieCitee = (saisie) => (String(saisie).trim() === '' ? 'La saisie est vide' : `${cite(String(saisie))} n'est pas un nombre entier`);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Les saisies (§ 6.2) ---------------------------------------------------
|
||||
[
|
||||
'NON_ENTIER',
|
||||
({ saisie }) => `${saisieCitee(saisie)} : un nombre entier, sans signe ni décimale, est attendu.`,
|
||||
],
|
||||
[
|
||||
'MOINS_DE_DEUX',
|
||||
() => "Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.",
|
||||
],
|
||||
['MOINS_D_UN_TOUR', () => 'Une soirée compte au moins un tour.'],
|
||||
|
||||
// --- Les refus d'une capacité (§ 6.2, § 7.1) -------------------------------
|
||||
[
|
||||
'SOUS_RESERVATIONS',
|
||||
({ n, tour }) =>
|
||||
`La table porte ${compte(n, 'réservation', 'réservations')} au tour ${tour} : ` +
|
||||
`elle ne descend pas sous ${compte(n, 'place', 'places')}.`,
|
||||
],
|
||||
[
|
||||
'SOUS_OCCUPES',
|
||||
({ n, tour }) =>
|
||||
`Au tour ${tour}, la table occupe ${compte(n, 'place', 'places')} : la poignée ne descend pas en deçà.`,
|
||||
],
|
||||
[
|
||||
'SIEGES_RETIRES',
|
||||
// Sièges non attribués (titres présent), un titre se compte : son siège
|
||||
// est une place d'affichage, que l'opérateur n'a pas choisie.
|
||||
({ sieges, titres }, texte) => {
|
||||
const listes = titres === undefined ? sieges : sieges.filter(({ cause }) => cause !== 'SIEGE_TITRE');
|
||||
const termes = listes.map((siege) => texte(siege.cause, siege));
|
||||
if (titres !== undefined && titres > 0) termes.push(compte(titres, 'siège porte un titre', 'sièges portent un titre'));
|
||||
return `Réduire la table retire des sièges qui portent une saisie : ${enumeration(termes)}.`;
|
||||
},
|
||||
],
|
||||
[
|
||||
'SIEGE_RESERVE',
|
||||
({ siege, suspendue }) => `le siège ${siege} est réservé${suspendue ? ', par une réservation suspendue' : ''}`,
|
||||
],
|
||||
['SIEGE_TITRE', ({ siege, libelle }) => `le siège ${siege} porte le titre ${cite(libelle)}`],
|
||||
|
||||
// --- Les avertissements d'une capacité (§ 6.2) -----------------------------
|
||||
[
|
||||
'SURNUMERAIRES',
|
||||
({ tour, personnes }) =>
|
||||
`Au tour ${tour}, ${compte(personnes.length, 'personne passe', 'personnes passent')} dans la réserve : ` +
|
||||
`la table n'a plus de chaise pour ${personnes.length <= 1 ? 'elle' : 'elles'}.`,
|
||||
],
|
||||
[
|
||||
'AU_DELA_LISIBLE',
|
||||
({ seuil }) => `Au-delà de ${seuil} places, la liste des noms de la table cesse d'être lisible sur le plan.`,
|
||||
],
|
||||
|
||||
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
|
||||
['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`],
|
||||
[
|
||||
'ENTREE_SUPPRIMER_TABLE',
|
||||
({ numero, reservations, titres }) => {
|
||||
const retires = [];
|
||||
if (reservations > 0) retires.push(compte(reservations, 'réservation', 'réservations'));
|
||||
if (titres > 0) retires.push(compte(titres, 'titre', 'titres'));
|
||||
return `Supprimé la table ${numero}${retires.length > 0 ? ` ; retirés : ${enumeration(retires)}` : ''}`;
|
||||
},
|
||||
],
|
||||
['ENTREE_DEPLACER_TABLE', ({ numero }) => `Déplacé la table ${numero}`],
|
||||
['ENTREE_CHANGER_PLACES_TABLE', ({ numero, sieges }) => `Table ${numero} : ${compte(sieges, 'place', 'places')}`],
|
||||
['ENTREE_REALIGNER_TABLE', ({ numero, sieges }) => `Table ${numero} : suit le défaut (${compte(sieges, 'place', 'places')})`],
|
||||
[
|
||||
'ENTREE_CHANGER_DEFAUT',
|
||||
({ sieges, realignees }) =>
|
||||
`Sièges par défaut : ${sieges}${realignees > 0 ? ` ; ${compte(realignees, 'table réalignée', 'tables réalignées')}` : ''}`,
|
||||
],
|
||||
[
|
||||
'ENTREE_CHANGER_TOURS',
|
||||
({ tours, retirees }) =>
|
||||
`Nombre de tours : ${tours}${retirees > 0 ? ` ; ${compte(retirees, 'réservation retirée', 'réservations retirées')}` : ''}`,
|
||||
],
|
||||
[
|
||||
'ENTREE_MODIFIER_REGLAGE',
|
||||
({ cle, valeur }) => `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${valeurDeReglage(valeur)}`,
|
||||
],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,33 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les
|
||||
// libellés de ses critères (§ 5, § 14.6).
|
||||
import { compte } from './aides.js';
|
||||
// libellés de ses critères (§ 5, § 14.6) : les codes d'une
|
||||
// ErreurConfiguration, ceux des violations de verifierInvariants et de
|
||||
// verifierIndicateurs, et moteur.critere.<nom> pour chaque nom de CRITERES.
|
||||
// Un code garde un seul texte : CAPACITE et TOURS, que le moteur lève sous
|
||||
// deux formes de détails — ErreurConfiguration et violation —, se
|
||||
// distinguent par les détails qu'ils portent. Une réservation que le moteur
|
||||
// désigne sans identifiant l'est par son rang à partir de 0, que le texte
|
||||
// affiche à partir de 1 ; une personne qu'une violation d'indicateur désigne
|
||||
// par son index se nomme par participant quand l'appelant l'a traduit, par
|
||||
// son rang sinon. INCONNU, aux mêmes détails que la faute de placement du
|
||||
// stockage, a son texte dans libelles/stockage.js.
|
||||
import { capitale, compte, nomDans } from './aides.js';
|
||||
|
||||
// Les cas d'une table que le plan ne rattache pas à l'instance, par detail.
|
||||
const CAS_DE_TABLE = new Map([
|
||||
['inconnue', (table) => `Le plan déclare la table d'identifiant ${table}, que l'événement n'a pas.`],
|
||||
['doublon', (table) => `Le plan déclare plus d'une fois la table d'identifiant ${table}.`],
|
||||
['absente', (table) => `Le plan ne déclare pas la table d'identifiant ${table}.`],
|
||||
]);
|
||||
|
||||
// La personne d'une violation d'indicateur : son numéro, ou son rang dans
|
||||
// la population quand l'appelant ne l'a pas traduit.
|
||||
const personneDe = ({ index, participant }) =>
|
||||
participant === undefined || participant === null ? `la personne de rang ${index + 1}` : `la personne n° ${participant}`;
|
||||
|
||||
// Un rang de réservation, compté à partir de 0 par le moteur.
|
||||
const reservationDe = (rang) => `la réservation n° ${rang + 1}`;
|
||||
|
||||
export const TABLE = new Map([
|
||||
// Une salle trop petite : avertissement d'un geste qui fait croître le
|
||||
|
|
@ -20,4 +45,110 @@ export const TABLE = new Map([
|
|||
`La table d'identifiant ${table} reçoit ${compte(reservees, 'personne réservée', 'personnes réservées')} ` +
|
||||
`au tour ${tour}, pour ${compte(capacite, 'place', 'places')}.`,
|
||||
],
|
||||
|
||||
// --- La configuration (normaliser, § 4, § 5.2) ------------------------------
|
||||
// CAPACITE {table, capacite} : une capacité refusée ; {table, tour} : une
|
||||
// liste du plan plus longue que sa table.
|
||||
[
|
||||
'CAPACITE',
|
||||
({ table, capacite, tour }) =>
|
||||
capacite === undefined
|
||||
? `Au tour ${tour}, la table d'identifiant ${table} reçoit plus de personnes qu'elle n'a de places.`
|
||||
: `La table d'identifiant ${table} a une capacité de ${capacite} : une table compte au moins 2 places.`,
|
||||
],
|
||||
// TOURS {tours} : un nombre de tours refusé ; {} : un plan qui n'a pas un
|
||||
// tour par tour de l'événement.
|
||||
[
|
||||
'TOURS',
|
||||
({ tours }) =>
|
||||
tours === undefined
|
||||
? "Le plan ne porte pas autant de tours que l'événement."
|
||||
: `Le nombre de tours, ${tours}, n'est pas un entier d'au moins 1.`,
|
||||
],
|
||||
['PARTICIPANT_DOUBLON', ({ participant }) => `La personne n° ${participant} figure plus d'une fois dans la liste.`],
|
||||
['TABLE_DOUBLON', ({ table }) => `La table d'identifiant ${table} figure plus d'une fois.`],
|
||||
[
|
||||
'RESERVATION_INCONNUE',
|
||||
({ reservation, participant, table }) =>
|
||||
participant === undefined
|
||||
? `${capitale(reservationDe(reservation))} désigne la table d'identifiant ${table}, que l'événement n'a pas.`
|
||||
: `${capitale(reservationDe(reservation))} désigne la personne n° ${participant}, que la liste n'a pas.`,
|
||||
],
|
||||
[
|
||||
'RESERVATION_TOUR',
|
||||
({ reservation, tour }) => `${capitale(reservationDe(reservation))} désigne le tour ${tour}, que l'événement n'a pas.`,
|
||||
],
|
||||
[
|
||||
'RESERVATION_CONFLIT',
|
||||
({ participant, tour, tables }) =>
|
||||
`La personne n° ${participant} est réservée au tour ${tour} à deux tables, ` +
|
||||
`d'identifiants ${tables[0]} et ${tables[1]}.`,
|
||||
],
|
||||
[
|
||||
'ENUMERATION_TROP_GRANDE',
|
||||
({ participant, limite }) =>
|
||||
`Le plafond a priori de la personne n° ${participant} demanderait d'énumérer plus de ${limite} itinéraires.`,
|
||||
],
|
||||
|
||||
// --- Le plan confronté à la configuration (indexerPlan) -------------------------
|
||||
[
|
||||
'PLAN_TOURS',
|
||||
({ attendu, tours, reserves }) =>
|
||||
`Le plan porte ${compte(tours, 'tour', 'tours')} et ${compte(reserves, 'réserve', 'réserves')}, ` +
|
||||
`pour ${compte(attendu, 'tour', 'tours')} dans l'événement.`,
|
||||
],
|
||||
['PLAN_TABLE_INCONNUE', ({ table }) => `Le plan déclare la table d'identifiant ${table}, que l'événement n'a pas.`],
|
||||
['PLAN_TABLE_DOUBLON', ({ table }) => `Le plan déclare plus d'une fois la table d'identifiant ${table}.`],
|
||||
['PLAN_TABLE_ABSENTE', ({ table }) => `Le plan ne déclare pas la table d'identifiant ${table}.`],
|
||||
[
|
||||
'PLAN_LISTES',
|
||||
({ tour, listes, tables }) =>
|
||||
`Au tour ${tour}, le plan porte ${compte(listes, 'liste', 'listes')} pour ${compte(tables, 'table', 'tables')}.`,
|
||||
],
|
||||
['PLAN_INCONNU', ({ participant, tour }) => `Au tour ${tour}, le plan place la personne n° ${participant}, que la liste n'a pas.`],
|
||||
['PLAN_EXCLU_PLACE', ({ participant, tour }) => `Au tour ${tour}, le plan place la personne n° ${participant}, qui est exclue.`],
|
||||
['PLAN_DOUBLE_PLACE', ({ participant, tour }) => `Au tour ${tour}, le plan place plus d'une fois la personne n° ${participant}.`],
|
||||
['PLAN_NON_ASSIS', ({ participant, tour }) => `Au tour ${tour}, le plan ne place nulle part la personne n° ${participant}.`],
|
||||
|
||||
// --- Les violations de verifierInvariants (§ 14.12) -----------------------------
|
||||
[
|
||||
'TABLE_INCONNUE',
|
||||
({ table, tour, detail }) =>
|
||||
detail === 'listes'
|
||||
? `Au tour ${tour}, le plan n'a pas une liste par table déclarée.`
|
||||
: nomDans(CAS_DE_TABLE, detail, 'cas de table')(table),
|
||||
],
|
||||
['DOUBLE_PLACE', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
|
||||
['EXCLU_PLACE', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} est placée alors qu'elle est exclue.`],
|
||||
['NON_ASSIS', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} n'est assise à aucune table.`],
|
||||
[
|
||||
'RESERVATION',
|
||||
({ participant, table, tour }) =>
|
||||
`Au tour ${tour}, la personne n° ${participant}, réservée à la table d'identifiant ${table}, est placée ailleurs.`,
|
||||
],
|
||||
|
||||
// --- Les violations de verifierIndicateurs (§ 12.3) -----------------------------
|
||||
[
|
||||
'PLAFOND_A_PRIORI',
|
||||
(details) => `Le plafond a priori de ${personneDe(details)} dépasse le nombre des autres personnes : le calcul est faux.`,
|
||||
],
|
||||
[
|
||||
'ORDRE_PLAFONDS',
|
||||
(details) => `Le plafond réalisé de ${personneDe(details)} dépasse son plafond a priori : le calcul est faux.`,
|
||||
],
|
||||
[
|
||||
'PLAFOND_REALISE',
|
||||
(details) => `Le plafond réalisé de ${personneDe(details)} dépasse le nombre des autres personnes : le calcul est faux.`,
|
||||
],
|
||||
[
|
||||
'DEPASSEMENT',
|
||||
(details) => `Les rencontres de ${personneDe(details)} dépassent son plafond réalisé : le calcul est faux.`,
|
||||
],
|
||||
|
||||
// --- Les critères du classement (§ 5.7) -----------------------------------------
|
||||
['moteur.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'],
|
||||
['moteur.critere.excedentCollisions', 'excédent de collisions'],
|
||||
['moteur.critere.collisionsCumulees', 'collisions cumulées'],
|
||||
['moteur.critere.rencontresRepetees', 'rencontres répétées'],
|
||||
['moteur.critere.redondance', "redondance d'appartenance"],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export const TABLE = new Map([
|
|||
],
|
||||
[
|
||||
'VERROU_REPRIS',
|
||||
({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`,
|
||||
({ depuis }, texte) => `Le verrou qu'une autre séance tenait${depuisQuand(depuis, texte)} est repris.`,
|
||||
],
|
||||
[
|
||||
'RETOUR_AUTOMATIQUE',
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ import {
|
|||
import { FORMAT, analyser } from '../stockage/document.js';
|
||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||
import { fil, reconstruire } from '../stockage/journal.js';
|
||||
import { SUFFIXES } from '../stockage/noms.js';
|
||||
import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js';
|
||||
import { avertissementsApres, invariantViole } from './avertissements.js';
|
||||
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
|
||||
|
|
@ -429,21 +430,35 @@ export function creerSeance({
|
|||
// null) ou illisible ne se reprend pas : VERROU_PRIS {seance, depuis,
|
||||
// vivant}, remède null, que verrouAutre garde. Un système sans verrou le
|
||||
// rend toujours pris. Un support qui refuse d'écrire le verrou, ou
|
||||
// d'effacer celui du mort, rompt le support sous l'échec
|
||||
// d'effacer celui qu'il reprend, rompt le support sous l'échec
|
||||
// VERROU_NON_ECRIT, et son ECRITURE remonte (§ 8.6). Rend les
|
||||
// avertissements.
|
||||
async function prendreVerrou(ouvert) {
|
||||
//
|
||||
// reprise : le verrou que l'opérateur a vu refusé et confirmé reprendre,
|
||||
// {seance, depuis} de verrouAutre (reprendreVerrou), null sinon. Un verrou
|
||||
// dont la vie ne se prouve pas (vivant null) s'efface alors, puis se prend,
|
||||
// sous VERROU_REPRIS {seance, depuis} — mais seulement le verrou même que
|
||||
// la reprise nomme, de même séance et de même instant : un verrou posé
|
||||
// depuis, que l'opérateur n'a pas vu, se refuse VERROU_PRIS comme sans
|
||||
// reprise. Un verrou vivant ne se reprend jamais.
|
||||
async function prendreVerrou(ouvert, reprise = null) {
|
||||
let verrou;
|
||||
const avertissements = [];
|
||||
try {
|
||||
verrou = await verrouiller(ouvert, id);
|
||||
if (!verrou.pris && verrou.seance === id) verrou = { pris: true };
|
||||
if (!verrou.pris && verrou.vivant === false) {
|
||||
const mort = verrou;
|
||||
await deverrouiller(ouvert, mort.seance);
|
||||
const confirme =
|
||||
!verrou.pris &&
|
||||
reprise !== null &&
|
||||
verrou.vivant === null &&
|
||||
verrou.seance === reprise.seance &&
|
||||
verrou.depuis === reprise.depuis;
|
||||
if (!verrou.pris && (verrou.vivant === false || confirme)) {
|
||||
const ancien = verrou;
|
||||
await effacerVerrou(ouvert, ancien.seance);
|
||||
verrou = await verrouiller(ouvert, id);
|
||||
if (verrou.pris) {
|
||||
avertissements.push(avertissement('VERROU_REPRIS', { seance: mort.seance, depuis: mort.depuis }));
|
||||
avertissements.push(avertissement('VERROU_REPRIS', { seance: ancien.seance, depuis: ancien.depuis }));
|
||||
}
|
||||
}
|
||||
} catch (erreur) {
|
||||
|
|
@ -460,6 +475,18 @@ export function creerSeance({
|
|||
return avertissements;
|
||||
}
|
||||
|
||||
// Efface le verrou d'une autre séance, que prendreVerrou reprend : celui
|
||||
// de la séance nommée, par deverrouiller, qui n'efface que lui ; un verrou
|
||||
// illisible (seance null) n'a pas de séance qui le rende, et son fichier
|
||||
// s'efface.
|
||||
async function effacerVerrou(ouvert, seance) {
|
||||
if (seance !== null) {
|
||||
await deverrouiller(ouvert, seance);
|
||||
return;
|
||||
}
|
||||
await ouvert.fs.supprimer(ouvert.racine, ouvert.base + SUFFIXES.verrou);
|
||||
}
|
||||
|
||||
// Rend le verrou que la séance tient sur l'événement de l'Ouvert. Un refus
|
||||
// du système de fichiers ne retient pas la séance : VERROU_NON_RENDU le
|
||||
// dit, et le verrou tombe avec ce processus.
|
||||
|
|
@ -911,7 +938,33 @@ export function creerSeance({
|
|||
if (estBloque(ouvert)) throw refusBloque();
|
||||
if (etat.mode === 'ecriture') return { avertissements: [] };
|
||||
exigerSupport();
|
||||
const avertissements = await prendreVerrou(ouvert);
|
||||
return entrerSousVerrou(ouvert, null);
|
||||
}
|
||||
|
||||
// Reprendre le verrou (§ 8.8) : le passage en écriture qui reprend le
|
||||
// verrou refusé que verrouAutre garde, une fois que l'opérateur l'a
|
||||
// confirmé en voyant sa séance et depuis quand (prendreVerrou). Sans
|
||||
// verrou refusé, c'est un passage en écriture. Le refus d'un débloquer
|
||||
// admet la reprise : sur un plan bloqué, ou relu bloqué, le verrou repris
|
||||
// se rend aussitôt, la séance reste en lecture, et VERROU_REPRIS se lit
|
||||
// parmi les avertissements du dernier geste ; débloquer passe ensuite.
|
||||
async function reprendreVerrou() {
|
||||
const ouvert = exigerOuvert();
|
||||
if (ouvert.formatPlusRecent) throw refusFormat();
|
||||
if (etat.mode === 'ecriture') return { avertissements: [] };
|
||||
exigerSupport();
|
||||
const reprise = etat.verrouAutre;
|
||||
return entrerSousVerrou(ouvert, reprise === null ? null : { seance: reprise.seance, depuis: reprise.depuis }, {
|
||||
bloqueAdmis: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Prend le verrou — en reprenant celui que reprise nomme —, relit
|
||||
// l'événement, et passe en écriture. Relu d'un format plus récent, il rend
|
||||
// le verrou et lève le refus ; relu bloqué, il rend le verrou, et lève le
|
||||
// refus sauf bloqueAdmis, qui rend les avertissements.
|
||||
async function entrerSousVerrou(ouvert, reprise, { bloqueAdmis = false } = {}) {
|
||||
const avertissements = await prendreVerrou(ouvert, reprise);
|
||||
avertissements.push(...(await relireSousVerrou(ouvert)));
|
||||
const relu = etat.ouvert;
|
||||
let refus = null;
|
||||
|
|
@ -919,6 +972,7 @@ export function creerSeance({
|
|||
else if (estBloque(relu)) refus = refusBloque();
|
||||
if (refus !== null) {
|
||||
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))];
|
||||
if (bloqueAdmis && !relu.formatPlusRecent) return { avertissements: [...etat.avertissementsGeste] };
|
||||
throw refus;
|
||||
}
|
||||
entrerEnEcriture();
|
||||
|
|
@ -1170,6 +1224,14 @@ export function creerSeance({
|
|||
return { avertissements };
|
||||
}),
|
||||
passerEnEcriture: () => enFile(passerEnEcriture),
|
||||
/**
|
||||
* Reprend le verrou que verrouAutre garde, après la confirmation de
|
||||
* l'opérateur qui a vu sa séance et depuis quand (§ 8.8) : l'efface,
|
||||
* prend celui de la séance et passe en écriture, sous VERROU_REPRIS
|
||||
* {seance, depuis}. Un verrou qui a changé depuis le refus se refuse
|
||||
* VERROU_PRIS ; un verrou vivant ne se reprend jamais.
|
||||
*/
|
||||
reprendreVerrou: () => enFile(reprendreVerrou),
|
||||
passerEnLecture: () => enFile(passerEnLecture),
|
||||
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
|
||||
debloquer: () => enFile(() => suivi(() => geste('debloquer', {}))),
|
||||
|
|
|
|||
|
|
@ -420,6 +420,165 @@ describe("séance : le verrou, pris à l'entrée en écriture (§ 8.8)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('séance : reprendre le verrou que la séance ne reprend pas seule (§ 8.8)', () => {
|
||||
// Un verrou posé sur le disque partagé par une séance d'un autre poste, à
|
||||
// l'instant depuis.
|
||||
const verrouLointain = (seance, depuis) => `${JSON.stringify({ seance, pid: 41, hote: 'poste-lointain', depuis })}\n`;
|
||||
|
||||
// Une séance d'épreuve qui ouvre en lecture l'événement d'essai, créé par
|
||||
// une première séance, déjà fermée, sur le même disque.
|
||||
async function secondeSeance(disque, hote = 'autre-poste') {
|
||||
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'createur' });
|
||||
await premier.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 });
|
||||
await premier.seance.fermer();
|
||||
const second = monter({ fs: creerFichiersSimules({ disque, hote }), prefixe: 'reprise' });
|
||||
await ouvrirSansEcrire(second);
|
||||
return second;
|
||||
}
|
||||
|
||||
test("le verrou d'un autre poste, refusé puis repris : il s'efface, la séance prend le sien et passe en écriture, VERROU_REPRIS le dit", async () => {
|
||||
const disque = new Map();
|
||||
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
||||
await enEcriture(premier);
|
||||
const { depuis } = verrouDe(premier.fs);
|
||||
const lointain = monter({ fs: creerFichiersSimules({ disque, hote: 'autre-poste' }), prefixe: 'lointaine' });
|
||||
await ouvrirSansEcrire(lointain);
|
||||
await refus(lointain.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis, vivant: null, remede: null });
|
||||
assert.deepEqual(lointain.seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null });
|
||||
const journal = lointain.fs.contenu('documents', JOURNAL);
|
||||
const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'premiere-1', depuis } }];
|
||||
assert.deepEqual(await lointain.seance.reprendreVerrou(), { avertissements: repris });
|
||||
assert.equal(lointain.seance.mode, 'ecriture');
|
||||
assert.equal(verrouDe(lointain.fs).seance, 'lointaine-1');
|
||||
assert.equal(lointain.fs.contenu('documents', JOURNAL), journal, 'reprendre n\u{2019}écrit aucune entrée');
|
||||
const etat = lointain.seance.etat();
|
||||
assert.equal(etat.verrouAutre, null);
|
||||
assert.deepEqual(etat.avertissements, repris);
|
||||
assert.equal(etat.refus, null);
|
||||
await lointain.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } });
|
||||
assert.deepEqual(noms(etatDe(lointain.fs).charge), ['Ombrelle']);
|
||||
});
|
||||
|
||||
test('un verrou illisible se reprend de même, sous VERROU_REPRIS {seance: null, depuis: null}', async () => {
|
||||
const disque = new Map();
|
||||
const second = await secondeSeance(disque, 'poste-epreuve');
|
||||
second.fs.deposer('documents', VERROU, 'pas un verrou\n');
|
||||
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null });
|
||||
const repris = [{ code: 'VERROU_REPRIS', details: { seance: null, depuis: null } }];
|
||||
assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris });
|
||||
assert.equal(second.seance.mode, 'ecriture');
|
||||
assert.equal(verrouDe(second.fs).seance, 'reprise-1');
|
||||
});
|
||||
|
||||
test("un verrou qui a changé depuis le refus ne s'efface pas : VERROU_PRIS nomme le nouveau, que verrouAutre garde", async () => {
|
||||
const disque = new Map();
|
||||
const second = await secondeSeance(disque);
|
||||
const vu = '2031-05-17T09:00:00-04:00';
|
||||
second.fs.deposer('documents', VERROU, verrouLointain('lointaine-1', vu));
|
||||
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis: vu, vivant: null, remede: null });
|
||||
const pose = '2031-05-17T09:30:00-04:00';
|
||||
const nouveau = verrouLointain('lointaine-2', pose);
|
||||
second.fs.deposer('documents', VERROU, nouveau);
|
||||
const details = { seance: 'lointaine-2', depuis: pose, vivant: null };
|
||||
await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null });
|
||||
assert.equal(second.fs.contenu('documents', VERROU), nouveau);
|
||||
assert.equal(second.seance.mode, 'lecture');
|
||||
assert.deepEqual(second.seance.etat().verrouAutre, details);
|
||||
const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'lointaine-2', depuis: pose } }];
|
||||
assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris });
|
||||
assert.equal(verrouDe(second.fs).seance, 'reprise-1');
|
||||
});
|
||||
|
||||
// Le verrou vu, puis remplacé par celui que décrit change : la reprise
|
||||
// le refuse VERROU_PRIS avec les détails du nouveau, ne touche pas au
|
||||
// fichier, et verrouAutre garde le nouveau.
|
||||
async function repriseRefusee(seance, depuis) {
|
||||
const second = await secondeSeance(new Map());
|
||||
const vu = '2031-05-17T09:00:00-04:00';
|
||||
second.fs.deposer('documents', VERROU, verrouLointain('lointaine-1', vu));
|
||||
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis: vu, vivant: null, remede: null });
|
||||
const nouveau = verrouLointain(seance, depuis);
|
||||
second.fs.deposer('documents', VERROU, nouveau);
|
||||
const details = { seance, depuis, vivant: null };
|
||||
await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null });
|
||||
assert.equal(second.fs.contenu('documents', VERROU), nouveau);
|
||||
assert.equal(second.seance.mode, 'lecture');
|
||||
assert.deepEqual(second.seance.etat().verrouAutre, details);
|
||||
}
|
||||
|
||||
test("la même séance reprise à un instant plus tard : le verrou n'est plus celui que l'opérateur a vu, il ne s'efface pas", async () => {
|
||||
await repriseRefusee('lointaine-1', '2031-05-17T09:45:00-04:00');
|
||||
});
|
||||
|
||||
test("une autre séance au même instant : le verrou n'est plus celui que l'opérateur a vu, il ne s'efface pas", async () => {
|
||||
await repriseRefusee('lointaine-5', '2031-05-17T09:00:00-04:00');
|
||||
});
|
||||
|
||||
test("le verrou d'une séance vivante de ce poste ne se reprend jamais", async () => {
|
||||
const disque = new Map();
|
||||
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
||||
await enEcriture(premier);
|
||||
const { depuis } = verrouDe(premier.fs);
|
||||
const voisin = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'voisine' });
|
||||
await ouvrirSansEcrire(voisin);
|
||||
const details = { seance: 'premiere-1', depuis, vivant: true };
|
||||
await refus(voisin.seance.passerEnEcriture(), 'VERROU_PRIS', { ...details, remede: null });
|
||||
await refus(voisin.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null });
|
||||
assert.equal(verrouDe(voisin.fs).seance, 'premiere-1');
|
||||
assert.equal(voisin.seance.mode, 'lecture');
|
||||
});
|
||||
|
||||
test("sans verrou refusé, reprendre est passer en écriture : un verrou posé depuis n'est pas repris", async () => {
|
||||
const second = await secondeSeance(new Map());
|
||||
const pose = '2031-05-17T10:00:00-04:00';
|
||||
second.fs.deposer('documents', VERROU, verrouLointain('lointaine-3', pose));
|
||||
await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { seance: 'lointaine-3', depuis: pose, vivant: null, remede: null });
|
||||
assert.equal(verrouDe(second.fs).seance, 'lointaine-3');
|
||||
const libre = await secondeSeance(new Map());
|
||||
assert.deepEqual(await libre.seance.reprendreVerrou(), { avertissements: [] });
|
||||
assert.deepEqual([libre.seance.mode, verrouDe(libre.fs).seance], ['ecriture', 'reprise-1']);
|
||||
});
|
||||
|
||||
test('sur un plan bloqué, la reprise rend aussitôt le verrou : la séance reste en lecture, et débloquer passe ensuite', async () => {
|
||||
const montage = monter({ prefixe: 'bloqueuse' });
|
||||
const { fs, seance } = montage;
|
||||
await enEcriture(montage);
|
||||
await seance.executer('changerEtat', { etat: 'bloque' });
|
||||
const depuis = '2031-05-17T11:00:00-04:00';
|
||||
fs.deposer('documents', VERROU, verrouLointain('lointaine-4', depuis));
|
||||
await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-4', depuis, vivant: null, remede: null });
|
||||
const journal = fs.contenu('documents', JOURNAL);
|
||||
const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'lointaine-4', depuis } }];
|
||||
assert.deepEqual(await seance.reprendreVerrou(), { avertissements: repris });
|
||||
assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['lecture', 'bloque']);
|
||||
assert.equal(verrouDe(fs), null);
|
||||
assert.equal(fs.contenu('documents', JOURNAL), journal);
|
||||
assert.deepEqual(seance.etat().avertissements, repris);
|
||||
assert.deepEqual(seance.etat().refus, { code: 'LECTURE_SEULE', details: BLOQUE_SEUL });
|
||||
await seance.debloquer();
|
||||
assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['ecriture', 'brouillon']);
|
||||
});
|
||||
|
||||
test("en écriture, reprendre ne fait rien ; un format plus récent et un support rompu le refusent sans rien écrire", async () => {
|
||||
const montage = monter();
|
||||
await enEcriture(montage);
|
||||
const avant = montage.fs.compteurs.ecritures;
|
||||
assert.deepEqual(await montage.seance.reprendreVerrou(), { avertissements: [] });
|
||||
assert.equal(montage.fs.compteurs.ecritures, avant);
|
||||
montage.fs.pannes.ecriture('documents');
|
||||
await refus(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }), 'ECRITURE');
|
||||
await montage.seance.passerEnLecture();
|
||||
const rompu = montage.fs.compteurs.ecritures;
|
||||
await refus(montage.seance.reprendreVerrou(), 'SUPPORT_ROMPU');
|
||||
assert.equal(montage.fs.compteurs.ecritures, rompu);
|
||||
const fsRecent = creerFichiersSimules();
|
||||
deposerAuFormat(fsRecent, FORMAT + 1);
|
||||
const recent = monter({ fs: fsRecent });
|
||||
await ouvrirSansEcrire(recent);
|
||||
await refus(recent.seance.reprendreVerrou(), 'LECTURE_SEULE', FORMAT_SEUL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("séance : l'inactivité (§ 8.4)", () => {
|
||||
test('au terme du délai, la séance revient seule en lecture et rend son verrou', async () => {
|
||||
const montage = monter();
|
||||
|
|
@ -2350,7 +2509,7 @@ describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () =
|
|||
assert.deepEqual(second.seance.etat().avertissements, repris);
|
||||
});
|
||||
|
||||
test("le verrou d'une instance d'un autre hôte (vivant null) n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé, et aucun geste de la séance ne le reprend", async () => {
|
||||
test("le verrou d'une instance d'un autre hôte (vivant null) n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé, et aucun geste ne le reprend seul : seul reprendreVerrou, que l'opérateur confirme, le reprend", async () => {
|
||||
const disque = new Map();
|
||||
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
||||
await enEcriture(premier);
|
||||
|
|
@ -2365,7 +2524,6 @@ describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () =
|
|||
assert.equal(premier.fs.contenu('documents', VERROU), texte);
|
||||
assert.deepEqual(seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null });
|
||||
assert.equal(seance.etat().mode, 'lecture');
|
||||
assert.equal(seance.reprendreVerrou, undefined);
|
||||
});
|
||||
|
||||
test("un verrou illisible n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé", async () => {
|
||||
|
|
@ -2641,7 +2799,7 @@ describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2)
|
|||
for (const motif of [/identifiant 3\b/, /tour 2\b/, /\b9 personnes\b/, /\b8 places\b/]) assert.match(texte, motif);
|
||||
});
|
||||
|
||||
test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou le dit d'une séance interrompue", () => {
|
||||
test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou la dit d'une autre séance, d'un poste ou d'un autre", () => {
|
||||
const derive = libelle('DERIVE_NOUVELLE', EXEMPLES_A2[2][1]);
|
||||
assert.ok(derive.includes(libelle('PARTICIPANT_EXCLU', { participant: 3 })), derive);
|
||||
assert.match(derive, /n° 2/);
|
||||
|
|
@ -2649,7 +2807,11 @@ describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2)
|
|||
const viole = libelle('INVARIANT_VIOLE', EXEMPLES_A2[1][1]);
|
||||
assert.ok(viole.includes(libelle('RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 })), viole);
|
||||
assert.match(viole, /n° 3/);
|
||||
assert.match(libelle('VERROU_REPRIS', { seance: 's', depuis: null }), /interrompue/);
|
||||
for (const details of [{ seance: 's', depuis: null }, { seance: null, depuis: null }]) {
|
||||
const repris = libelle('VERROU_REPRIS', details);
|
||||
assert.match(repris, /une autre séance/);
|
||||
assert.doesNotMatch(repris, /interrompue|ce poste/);
|
||||
}
|
||||
assert.match(libelle('CONFIGURATION_INCOHERENTE', { code: 'CODE_INVENTE', details: {} }), /« CODE_INVENTE »/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,10 +83,12 @@ export const CONSTANTES = Object.freeze(
|
|||
},
|
||||
{
|
||||
nom: 'LARGEUR_BLOC_NOMS_CM',
|
||||
valeur: 180,
|
||||
valeur: 500,
|
||||
unite: 'cm',
|
||||
mesure: "largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège",
|
||||
raison: 'un nom tient sur une ligne du plan sans recouvrir la table voisine (§ 7.4)',
|
||||
raison:
|
||||
"la ligne entière d'une place réservée et titrée (numéro, état, nom, titre) tient sur une ligne " +
|
||||
'du plan au corps des listes, la liste faisant foi (§ 7.4, § 7.6)',
|
||||
configuration: 'grande démonstration au zoom par défaut',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const CONTRAT = [
|
|||
['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel'],
|
||||
['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc'],
|
||||
['K_LISTES', 0.3, 'px/cm', 'banc'],
|
||||
['LARGEUR_BLOC_NOMS_CM', 180, 'cm', 'manuel'],
|
||||
['LARGEUR_BLOC_NOMS_CM', 500, 'cm', 'manuel'],
|
||||
['HAUTEUR_LIGNE', null, 'cm', 'execution'],
|
||||
];
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
|
|||
test('constante rend la valeur par le nom ; un nom inconnu lève', () => {
|
||||
assert.equal(constante('SEUIL_GLISSER_PX'), 4);
|
||||
assert.equal(constante('K_LISTES'), 0.3);
|
||||
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 180);
|
||||
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 500);
|
||||
assert.equal(constante('HAUTEUR_LIGNE'), null);
|
||||
assert.throws(() => constante('INCONNUE'), RangeError);
|
||||
assert.throws(() => constante('nom'), RangeError);
|
||||
|
|
|
|||
76
src/interface/APropos.svelte
Normal file
76
src/interface/APropos.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le panneau « À propos » (§ 18.6) : le bloc d'une ligne que l'opérateur
|
||||
// copie dans un courriel, tel que blocAPropos l'a composé, et sa copie dans
|
||||
// le presse-papiers de la plateforme. « Copier » remet exactement le texte
|
||||
// affiché ; l'issue de la copie se lit sous les commandes.
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
|
||||
let { bloc, pressePapiers, fermer } = $props();
|
||||
|
||||
// null avant toute copie, puis 'faite' ou 'refusee'.
|
||||
let issue = $state(null);
|
||||
|
||||
async function copier() {
|
||||
try {
|
||||
await pressePapiers.copier(bloc);
|
||||
issue = 'faite';
|
||||
} catch {
|
||||
issue = 'refusee';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="apropos" data-ecran="apropos">
|
||||
<h1>{t('coquille.apropos.titre')}</h1>
|
||||
<p class="bloc" data-bloc>{bloc}</p>
|
||||
<div class="commandes">
|
||||
<button type="button" data-commande="copier" onclick={copier}>{t('coquille.apropos.copier')}</button>
|
||||
<button type="button" data-commande="fermerApropos" onclick={fermer}>{t('coquille.apropos.fermer')}</button>
|
||||
</div>
|
||||
{#if issue === 'faite'}
|
||||
<p role="status">{t('coquille.apropos.copie')}</p>
|
||||
{:else if issue === 'refusee'}
|
||||
<p role="status" class="refus" data-refus="copier">{t('coquille.apropos.copieRefusee')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.bloc {
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
font-family: monospace;
|
||||
user-select: all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.refus {
|
||||
color: var(--couleur-refus);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,27 +1,40 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuve de rendu du bandeau d'application (§ 8.4, § 18.6), dans un vrai
|
||||
// moteur de rendu. 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
|
||||
// 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 } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
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,
|
||||
|
|
@ -33,47 +46,676 @@ import {
|
|||
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'];
|
||||
|
||||
let composant;
|
||||
// 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(() => {
|
||||
composant = mount(App, { target: document.body });
|
||||
flushSync();
|
||||
erreursAvant = globalThis.__gttErreurs?.length ?? 0;
|
||||
erreursAdmises = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unmount(composant);
|
||||
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([]);
|
||||
});
|
||||
|
||||
function bandeau() {
|
||||
return document.body.querySelector('header.bandeau');
|
||||
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.
|
||||
function bandeauEtTextes() {
|
||||
return [{ element: bandeau(), pseudo: null }, ...porteursDeTexte(bandeau())];
|
||||
}
|
||||
const bandeauEtTextes = (m) => [{ element: bandeau(m), pseudo: null }, ...porteursDeTexte(bandeau(m))];
|
||||
|
||||
describe("bandeau d'application", () => {
|
||||
test('existe et porte la version affichée', () => {
|
||||
expect(bandeau()).not.toBeNull();
|
||||
expect(bandeau().textContent).toContain(VERSION.affichee);
|
||||
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 et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
|
||||
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();
|
||||
expect(porteurs.length).toBeGreaterThan(1);
|
||||
const porteurs = bandeauEtTextes(m);
|
||||
expect(porteurs.length).toBeGreaterThan(4);
|
||||
for (const porteur of porteurs) {
|
||||
expect(visible(porteur), `${theme} : ${designation(porteur)}`).toBe(true);
|
||||
}
|
||||
|
|
@ -81,29 +723,20 @@ describe("bandeau d'application", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('porte la provenance de la construction', () => {
|
||||
expect(import.meta.env.MODE).toBe('test');
|
||||
expect(bandeau().textContent).toContain('épreuve');
|
||||
});
|
||||
|
||||
test('le titre de la fenêtre porte le nom et la version affichée', () => {
|
||||
expect(document.title).toBe(
|
||||
`Gestion table tournante Libre — ${VERSION.affichee}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('le texte et le fond du bandeau ont une couleur calculée', () => {
|
||||
const style = getComputedStyle(bandeau());
|
||||
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, dans les deux thèmes (§ 14.5)', async () => {
|
||||
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());
|
||||
expect(textes.length).toBeGreaterThan(0);
|
||||
const textes = porteursDeTexte(bandeau(m));
|
||||
expect(textes.length).toBeGreaterThan(4);
|
||||
for (const porteur of textes) {
|
||||
const lieu = `${theme} : ${designation(porteur)}`;
|
||||
const texte = couleurTexte(porteur);
|
||||
|
|
@ -114,7 +747,7 @@ describe("bandeau d'application", () => {
|
|||
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
return [document.body, bandeau()].map((el) => {
|
||||
return [document.body, bandeau(m)].map((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return `${composantes(style.color)} sur ${composantes(style.backgroundColor)}`;
|
||||
});
|
||||
|
|
@ -129,14 +762,15 @@ describe("bandeau d'application", () => {
|
|||
).not.toEqual(couleursParTheme[0]);
|
||||
});
|
||||
|
||||
test("aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
|
||||
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());
|
||||
expect(textes.length).toBeGreaterThan(0);
|
||||
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}`,
|
||||
|
|
|
|||
|
|
@ -2,27 +2,276 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Racine de l'interface. Le bandeau d'application, présent sur tout écran,
|
||||
// porte le nom, la version affichée et la provenance de la construction ;
|
||||
// le nom suivi de la version affichée devient aussi le titre de la fenêtre
|
||||
// (§ 8.4, § 18.6). La provenance vient du mode de Vite, que pose la
|
||||
// commande de construction.
|
||||
// Racine de l'interface (§ 13.4). Ses propriétés sont les dépendances de
|
||||
// l'application — système de fichiers, horloge, exécuteur de génération,
|
||||
// presse-papiers, identifiants, langue, provenance, démonstrations, format
|
||||
// courant, version du produit, et en option le thème de départ — que
|
||||
// src/main.js construit pour la machine et monterApp pour une épreuve : App
|
||||
// ne choisit aucune plateforme.
|
||||
//
|
||||
// À son initialisation, App crée une fois chacun la séance, sans racine,
|
||||
// le contrôleur de génération et la façade, et les donne aux composants par
|
||||
// le contexte de Svelte — clés 'seance', 'facade', 'generation', 'ui', 't',
|
||||
// 'format', 'horloge' et 'relireListe' —, posé de façon synchrone avant que
|
||||
// la détermination du dossier de travail, asynchrone, ne rende. La façade
|
||||
// pose la racine de la séance par changerRacine : aussitôt sans question,
|
||||
// après la réponse sinon. App lit la liste du dossier de travail par la
|
||||
// façade et la donne à l'accueil, une fois la détermination réglée — sans
|
||||
// question au démarrage, après la réponse sinon : pret se résout quand la
|
||||
// détermination est faite, la liste lue quand aucune question n'attend, et
|
||||
// le premier écran — les lignes de la liste, ou la question — rendu.
|
||||
//
|
||||
// L'écran suit la séance : la liste sans événement ouvert, l'événement
|
||||
// sinon ; le panneau « À propos » les remplace tant qu'il est ouvert. Chaque
|
||||
// ouverture d'un événement remet l'état de navigation à son départ. Le
|
||||
// bandeau, présent sur tout écran, ne défile pas : un écran plus haut que
|
||||
// la fenêtre défile sous lui.
|
||||
//
|
||||
// Le thème part de la propriété themeInitial, ou de la préférence du
|
||||
// système lue au montage ; le réglage de l'opérateur le change pour la
|
||||
// séance, en mémoire, posé en data-theme sur <html> (§ 19.2). À la sortie
|
||||
// de la page (pagehide), la séance ferme l'événement et rend son verrou,
|
||||
// sans question ; aucun beforeunload ne retient la page (§ 8.4).
|
||||
import './jetons.css';
|
||||
import { onDestroy, setContext, tick, untrack } from 'svelte';
|
||||
import { creerFacade } from '../application/evenements.js';
|
||||
import { creerFormat } from '../application/format.js';
|
||||
import { creerControleurGeneration } from '../application/generation.js';
|
||||
import { TABLES_APPLICATION } from '../application/libelles.js';
|
||||
import { creerSeance } from '../application/seance.js';
|
||||
import { creerTraduction } from '../application/traduction.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { libelleProvenance, titreAvecVersion } from './libelles.js';
|
||||
import APropos from './APropos.svelte';
|
||||
import Accueil from './accueil/Accueil.svelte';
|
||||
import Bandeau from './Bandeau.svelte';
|
||||
import Evenement from './evenement/Evenement.svelte';
|
||||
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
|
||||
import { creerEtatUi } from './etat_ui.js';
|
||||
import { TABLES_INTERFACE } from './libelles.js';
|
||||
import { themeInitial as themeDuSysteme } from './theme.js';
|
||||
|
||||
const titre = titreAvecVersion(VERSION.affichee);
|
||||
const provenance = libelleProvenance(import.meta.env.MODE);
|
||||
// Les dépendances se lisent une fois, au montage : App crée ses services
|
||||
// une seule fois, et un changement de propriété ne les recrée pas.
|
||||
const dependances = $props();
|
||||
const {
|
||||
fs,
|
||||
horloge,
|
||||
executeur,
|
||||
pressePapiers,
|
||||
identifiant,
|
||||
langue = 'fr',
|
||||
provenance,
|
||||
demonstrations,
|
||||
formatCourant,
|
||||
produitVersion,
|
||||
themeInitial,
|
||||
} = untrack(() => dependances);
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue });
|
||||
const format = creerFormat(t);
|
||||
const seanceApp = creerSeance({
|
||||
fs,
|
||||
horloge,
|
||||
identifiant,
|
||||
produitVersion,
|
||||
planifier: horloge.planifier,
|
||||
annuler: horloge.annuler,
|
||||
formatCourant,
|
||||
});
|
||||
const generationApp = creerControleurGeneration({ seance: seanceApp, executeur, horloge, produitVersion });
|
||||
const facadeApp = creerFacade({
|
||||
fs,
|
||||
horloge,
|
||||
identifiant,
|
||||
produitVersion,
|
||||
demonstrations,
|
||||
ouvert: () => seanceApp.etat().evenement?.base ?? null,
|
||||
ouvrir: (base) => seanceApp.ouvrir(base),
|
||||
fermer: () => seanceApp.fermer(),
|
||||
creer: (champs) => seanceApp.creer(champs),
|
||||
racine: () => seanceApp.etat().racine,
|
||||
changerRacine: (racine) => seanceApp.changerRacine(racine),
|
||||
});
|
||||
// La façade que reçoivent les composants, et que rend l'export facade :
|
||||
// celle de creerFacade, dont demarrer et repondre relisent la liste une
|
||||
// fois la détermination réglée sans question. La façade appelle
|
||||
// changerRacine avant de retirer sa question, si bien qu'une lecture
|
||||
// lancée de ce rappel serait refusée (QUESTION_EN_ATTENTE). Une
|
||||
// détermination refusée ne laisse rien : le demarrer suivant la refait, et
|
||||
// relit alors la liste.
|
||||
const facadeContexte = {
|
||||
...facadeApp,
|
||||
demarrer: async () => {
|
||||
const issue = await facadeApp.demarrer();
|
||||
if (issue.dossier.question === null) await relireListe();
|
||||
return issue;
|
||||
},
|
||||
repondre: async (choix) => {
|
||||
const issue = await facadeApp.repondre(choix);
|
||||
if (issue.dossier.question === null) await relireListe();
|
||||
return issue;
|
||||
},
|
||||
};
|
||||
const uiApp = creerEtatUi();
|
||||
|
||||
setContext('seance', seanceApp);
|
||||
setContext('facade', facadeContexte);
|
||||
setContext('relireListe', () => relireListe());
|
||||
setContext('generation', generationApp);
|
||||
setContext('ui', uiApp);
|
||||
setContext('t', t);
|
||||
setContext('format', format);
|
||||
setContext('horloge', horloge);
|
||||
|
||||
// Un refus de la séance ou de la façade porte un code, que l'écran
|
||||
// traduit ; toute autre erreur est une faute, qui remonte.
|
||||
const estRefus = (erreur) => typeof erreur?.code === 'string';
|
||||
const refusDe = (erreur) => ({ code: erreur.code, details: erreur.details });
|
||||
|
||||
// La liste du dossier de travail, que l'accueil montre, et le refus qui en
|
||||
// tient lieu quand la façade refuse de la lire : une liste refusée ne
|
||||
// montre plus les lignes d'une lecture d'avant. lecture est la promesse de
|
||||
// la dernière lecture lancée, que preparer attend ; quand deux lectures se
|
||||
// chevauchent, seule la dernière pose ce qu'elle lit. La lecture passe par
|
||||
// facadeContexte, la façade que voient les composants et les épreuves.
|
||||
let lignes = $state.raw([]);
|
||||
let refusLecture = $state.raw(null);
|
||||
let lecture = Promise.resolve();
|
||||
let lectures = 0;
|
||||
|
||||
async function lireListe() {
|
||||
lectures += 1;
|
||||
const rang = lectures;
|
||||
try {
|
||||
const lues = await facadeContexte.lister();
|
||||
if (rang !== lectures) return;
|
||||
lignes = lues;
|
||||
refusLecture = null;
|
||||
} catch (erreur) {
|
||||
if (!estRefus(erreur)) throw erreur;
|
||||
if (rang !== lectures) return;
|
||||
lignes = [];
|
||||
refusLecture = refusDe(erreur);
|
||||
}
|
||||
}
|
||||
|
||||
// Lance une lecture de la liste, qui devient la dernière, et rend sa
|
||||
// promesse. L'accueil l'appelle par le contexte après toute opération qui
|
||||
// change le dossier de travail.
|
||||
function relireListe() {
|
||||
lecture = lireListe();
|
||||
return lecture;
|
||||
}
|
||||
|
||||
// L'état observable de la séance, et la base de l'événement ouvert : une
|
||||
// base qui change vers un événement remet la navigation à son départ. Le
|
||||
// refus de « Liste des événements » tient jusqu'au changement suivant.
|
||||
// La liste se relit à chaque fermeture d'un événement, qui a pu en créer,
|
||||
// en changer ou écrire ailleurs ; elle se lit aussi quand la détermination
|
||||
// se règle — preparer sans question, repondre de facadeContexte après la
|
||||
// réponse.
|
||||
let etatSeance = $state.raw(seanceApp.etat());
|
||||
let refusListe = $state.raw(null);
|
||||
let baseOuverte = null;
|
||||
const desabonner = seanceApp.subscribe((etat) => {
|
||||
const base = etat.evenement?.base ?? null;
|
||||
if (base !== null && base !== baseOuverte) uiApp.reinitialiser();
|
||||
const fermeture = baseOuverte !== null && base === null;
|
||||
baseOuverte = base;
|
||||
etatSeance = etat;
|
||||
refusListe = null;
|
||||
if (fermeture) relireListe();
|
||||
});
|
||||
onDestroy(desabonner);
|
||||
|
||||
let theme = $state(themeInitial ?? themeDuSysteme(window.matchMedia('(prefers-color-scheme: dark)').matches));
|
||||
$effect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
});
|
||||
|
||||
const titre = t('coquille.titre', { version: produitVersion });
|
||||
const version = versionVisible(provenance, produitVersion, t);
|
||||
const provenanceAffichee = provenanceVisible(provenance, t);
|
||||
const bloc = blocAPropos({ version: VERSION, plateforme: fs.nature, provenance, format: formatCourant }, t);
|
||||
|
||||
// « Liste des événements » : ferme l'événement, sans question ; sous un
|
||||
// support rompu, la séance le refuse, et le refus s'affiche à côté de la
|
||||
// commande.
|
||||
async function revenirALaListe() {
|
||||
try {
|
||||
await seanceApp.fermer();
|
||||
} catch (erreur) {
|
||||
if (!estRefus(erreur)) throw erreur;
|
||||
refusListe = refusDe(erreur);
|
||||
}
|
||||
}
|
||||
|
||||
// La page se retire : la séance ferme l'événement et rend son verrou. Sous
|
||||
// un support rompu, elle le refuse et rien ne s'écrit.
|
||||
function quitterLaPage() {
|
||||
seanceApp.fermer().catch((erreur) => {
|
||||
if (!estRefus(erreur)) throw erreur;
|
||||
});
|
||||
}
|
||||
|
||||
// La détermination faite sans question, la liste du dossier retenu se
|
||||
// lit ; une question laisse l'accueil sans lignes, jusqu'à la réponse. Un
|
||||
// refus de la détermination s'affiche à la place de la liste.
|
||||
async function preparer() {
|
||||
try {
|
||||
await facadeContexte.demarrer();
|
||||
} catch (erreur) {
|
||||
if (!estRefus(erreur)) throw erreur;
|
||||
refusLecture = refusDe(erreur);
|
||||
}
|
||||
await lecture;
|
||||
await tick();
|
||||
}
|
||||
|
||||
/** Résolue quand la détermination du dossier de travail est faite et le
|
||||
* premier écran rendu : les lignes de la liste lue, ou la question. */
|
||||
export const pret = preparer();
|
||||
/** La séance de ce montage. */
|
||||
export function seance() {
|
||||
return seanceApp;
|
||||
}
|
||||
/** Le contrôleur de génération de ce montage. */
|
||||
export function generation() {
|
||||
return generationApp;
|
||||
}
|
||||
/** La façade des événements de ce montage. */
|
||||
export const facade = facadeContexte;
|
||||
/** L'état de navigation de ce montage. */
|
||||
export const ui = uiApp;
|
||||
</script>
|
||||
|
||||
<svelte:window onpagehide={quitterLaPage} />
|
||||
|
||||
<svelte:head>
|
||||
<title>{titre}</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="bandeau">
|
||||
<span class="titre">{titre}</span>
|
||||
<span class="provenance">{provenance}</span>
|
||||
</header>
|
||||
<div class="app">
|
||||
<Bandeau
|
||||
etat={etatSeance}
|
||||
{version}
|
||||
provenance={provenanceAffichee}
|
||||
{theme}
|
||||
{refusListe}
|
||||
choisirTheme={(choix) => (theme = choix)}
|
||||
ouvrirApropos={() => uiApp.ouvrirApropos()}
|
||||
{revenirALaListe}
|
||||
/>
|
||||
<main class="ecran">
|
||||
{#if $uiApp.apropos}
|
||||
<APropos {bloc} {pressePapiers} fermer={() => uiApp.fermerApropos()} />
|
||||
{:else if etatSeance.evenement === null}
|
||||
<Accueil {provenance} version={produitVersion} {demonstrations} {lignes} {refusLecture} />
|
||||
{:else}
|
||||
<Evenement />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
|
|
@ -32,21 +281,14 @@
|
|||
font-family: var(--police-interface);
|
||||
}
|
||||
|
||||
.bandeau {
|
||||
.app {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-3);
|
||||
padding: var(--espace-2) var(--espace-4);
|
||||
border-bottom: 1px solid var(--couleur-bandeau-filet);
|
||||
background-color: var(--couleur-bandeau-fond);
|
||||
color: var(--couleur-bandeau-texte);
|
||||
font-size: var(--taille-texte-bandeau);
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.provenance {
|
||||
padding: 0 var(--espace-2);
|
||||
border: 1px solid var(--couleur-bandeau-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
.ecran {
|
||||
flex: 1;
|
||||
padding: var(--espace-4);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
151
src/interface/Bandeau.svelte
Normal file
151
src/interface/Bandeau.svelte
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le bandeau d'application, présent sur tout écran (§ 8.4, § 18.6) : le
|
||||
// nom, la version — ou la marque de provenance d'une construction de
|
||||
// documentation —, la provenance, et, quand un événement est ouvert, le
|
||||
// libellé de mode, l'annonce de format du fichier et la commande « Liste
|
||||
// des événements ». Il porte aussi le réglage de thème, deux commandes à
|
||||
// aria-pressed, et l'ouverture du panneau « À propos ». Collé en haut de la
|
||||
// fenêtre, il ne défile pas.
|
||||
//
|
||||
// Le bandeau ne décide de rien : App lui donne l'état de la séance, la
|
||||
// version et la provenance telles qu'elles s'affichent, le thème et les
|
||||
// gestes à appeler ; mode.js choisit les clés du libellé de mode et de
|
||||
// l'annonce. Le refus de « Liste des événements » passe par la primitive
|
||||
// Refus, qui pose son texte et son remède en Commande — écrire ailleurs
|
||||
// sous un support rompu. Ce refus se pose sous les commandes, hors du
|
||||
// flux : le bandeau garde sa hauteur, et l'écran de l'événement, sa place
|
||||
// fixe comprise, ne bouge pas (§ 8.4).
|
||||
import { getContext } from 'svelte';
|
||||
import { cleAnnonceFormat, cleDeMode } from './mode.js';
|
||||
import Refus from './primitives/Refus.svelte';
|
||||
import { THEMES, cleDeTheme } from './theme.js';
|
||||
|
||||
const t = getContext('t');
|
||||
|
||||
let { etat, version, provenance, theme, refusListe, choisirTheme, ouvrirApropos, revenirALaListe } = $props();
|
||||
|
||||
const cleMode = $derived(cleDeMode(etat));
|
||||
const cleAnnonce = $derived(cleAnnonceFormat(etat));
|
||||
</script>
|
||||
|
||||
<header class="bandeau" data-bandeau>
|
||||
<span class="nom">{t('coquille.nom')}</span>
|
||||
<span class="version">{version}</span>
|
||||
<span class="provenance">{provenance}</span>
|
||||
{#if cleMode !== null}
|
||||
<span class="mode" data-mode-libelle="bandeau">{t(cleMode)}</span>
|
||||
{/if}
|
||||
{#if cleAnnonce !== null}
|
||||
<span class="annonce" data-avertissement="annonceFormat">{t(cleAnnonce)}</span>
|
||||
{/if}
|
||||
<span class="commandes">
|
||||
{#if etat.evenement !== null}
|
||||
<button type="button" data-commande="liste" onclick={revenirALaListe}>{t('coquille.liste')}</button>
|
||||
{#if refusListe !== null}
|
||||
<div class="refus-liste" data-refus="liste">
|
||||
<Refus refus={refusListe} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="themes" role="group" aria-label={t('coquille.theme.titre')}>
|
||||
{#each THEMES as choix (choix)}
|
||||
<button
|
||||
type="button"
|
||||
data-commande="theme"
|
||||
data-theme-choix={choix}
|
||||
aria-pressed={theme === choix}
|
||||
onclick={() => choisirTheme(choix)}>{t(cleDeTheme(choix))}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
<button type="button" data-commande="apropos" onclick={ouvrirApropos}>{t('coquille.apropos.titre')}</button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.bandeau {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
padding: var(--espace-2) var(--espace-4);
|
||||
border-bottom: 1px solid var(--couleur-bandeau-filet);
|
||||
background-color: var(--couleur-bandeau-fond);
|
||||
color: var(--couleur-bandeau-texte);
|
||||
font-size: var(--taille-texte-bandeau);
|
||||
}
|
||||
|
||||
.provenance,
|
||||
.mode,
|
||||
.annonce {
|
||||
padding: 0 var(--espace-2);
|
||||
border: 1px solid var(--couleur-bandeau-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
|
||||
.mode {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Le refus garde les couleurs de la page, sur lesquelles sa couleur et
|
||||
celles de sa commande tiennent leur contraste : le fond du bandeau ne
|
||||
passe pas sous lui. Posé sous les commandes, aligné à leur droite, il
|
||||
recouvre l'écran au lieu de le pousser. Sa largeur bornée le tient à
|
||||
droite, loin de la place fixe, qui ouvre l'écran à gauche, et dans la
|
||||
fenêtre. */
|
||||
.refus-liste {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
width: max-content;
|
||||
max-width: min(32rem, calc(100vw - 2 * var(--espace-4)));
|
||||
margin-top: var(--espace-1);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
.commandes,
|
||||
.themes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.commandes {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-bandeau-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-bandeau-fond);
|
||||
color: var(--couleur-bandeau-texte);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Le thème retenu se distingue par sa forme autant que par ses couleurs :
|
||||
texte souligné et gras, couleurs du bandeau inversées. */
|
||||
button[aria-pressed='true'] {
|
||||
background-color: var(--couleur-bandeau-texte);
|
||||
color: var(--couleur-bandeau-fond);
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
1002
src/interface/accueil/Accueil.navigateur.test.js
Normal file
1002
src/interface/accueil/Accueil.navigateur.test.js
Normal file
File diff suppressed because it is too large
Load diff
350
src/interface/accueil/Accueil.svelte
Normal file
350
src/interface/accueil/Accueil.svelte
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le premier écran (§ 8.1, § 8.6, § 18.6) : la liste des événements du
|
||||
// dossier de travail, avec ses trois issues toujours offertes — la liste,
|
||||
// la création, les démonstrations —, l'import d'un événement, la corbeille
|
||||
// et le dossier de travail, ses avis et sa question. Il ouvre un événement
|
||||
// en lecture par la séance ; ses autres gestes passent par la façade (A3),
|
||||
// et il ne lit ni n'écrit rien lui-même.
|
||||
//
|
||||
// Les lignes viennent d'App, qui lit la liste par la façade une fois la
|
||||
// détermination du dossier de travail réglée, et refusLecture est le refus
|
||||
// qu'App a reçu en la lisant. L'accueil prend la détermination par
|
||||
// facade.demarrer — la même pour toute la séance, sans nouvelle sonde — et
|
||||
// en montre la question, puis l'autre emplacement, les avis et le dossier.
|
||||
// Les avis ne se disent qu'au premier accueil de la séance (premiereAnnonce)
|
||||
// : un retour à la liste ne les répète pas. Toute opération qui change le
|
||||
// dossier de travail demande ensuite à App de relire la liste, par
|
||||
// relireListe du contexte, et relit la corbeille.
|
||||
//
|
||||
// Un état qui ne s'ouvre pas (ETAT_ILLISIBLE) mène à la récupération, qui
|
||||
// tient lieu de liste jusqu'à son retour. Chaque refus s'affiche, traduit,
|
||||
// là où son geste a eu lieu, sans remède : sur la liste, aucun événement
|
||||
// n'est ouvert pour l'exécuter (refusDeLaListe).
|
||||
import { getContext, onDestroy, untrack } from 'svelte';
|
||||
import { versionVisible } from '../apropos.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import Corbeille from './Corbeille.svelte';
|
||||
import Demonstrations from './Demonstrations.svelte';
|
||||
import DossierTravail from './DossierTravail.svelte';
|
||||
import ListeEvenements from './ListeEvenements.svelte';
|
||||
import Recuperation from './Recuperation.svelte';
|
||||
import { champDuRefus, premiereAnnonce, refusDeLaListe, surLaListe } from './modele.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const facade = getContext('facade');
|
||||
const seance = getContext('seance');
|
||||
const relireListe = getContext('relireListe');
|
||||
|
||||
let { provenance, version, demonstrations, lignes, refusLecture } = $props();
|
||||
|
||||
// Vrai au premier accueil de la séance : ses avis s'y disent.
|
||||
const annoncer = premiereAnnonce(facade);
|
||||
const uid = $props.id();
|
||||
|
||||
// La détermination du dossier de travail, { dossier, avis }, null tant
|
||||
// qu'elle n'est pas connue ; le dossier de travail de la séance.
|
||||
let issue = $state.raw(null);
|
||||
let racine = $state.raw(seance.etat().racine);
|
||||
let corbeille = $state.raw([]);
|
||||
let importAutre = $state.raw(null);
|
||||
// La récupération d'un état illisible : { base, chemin, refus }.
|
||||
let recuperation = $state.raw(null);
|
||||
let refusOuverture = $state.raw(null);
|
||||
let refusDemonstration = $state.raw(null);
|
||||
// Le formulaire de création, et son refus.
|
||||
let nom = $state('');
|
||||
let date = $state('');
|
||||
let refusCreation = $state.raw(null);
|
||||
const champRefuse = $derived(champDuRefus(refusCreation));
|
||||
|
||||
const question = $derived(issue?.dossier.question ?? null);
|
||||
const avis = $derived(annoncer ? (issue?.avis ?? []) : []);
|
||||
|
||||
let detruit = false;
|
||||
const desabonner = seance.subscribe((etat) => {
|
||||
racine = etat.racine;
|
||||
});
|
||||
onDestroy(() => {
|
||||
detruit = true;
|
||||
desabonner();
|
||||
});
|
||||
|
||||
// La corbeille du dossier de travail, quand aucune question n'attend ; un
|
||||
// refus de la lire la laisse vide.
|
||||
async function lireCorbeille() {
|
||||
if (question !== null) return;
|
||||
try {
|
||||
const lue = await facade.corbeille();
|
||||
if (!detruit) corbeille = lue;
|
||||
} catch (erreur) {
|
||||
if (refusDeLaListe(erreur) === null) throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Une détermination rendue par demarrer ou repondre devient celle de
|
||||
// l'accueil ; refusée, elle laisse le refus qu'App affiche.
|
||||
async function prendreIssue(promesse) {
|
||||
let rendue;
|
||||
try {
|
||||
rendue = await promesse;
|
||||
} catch (erreur) {
|
||||
if (refusDeLaListe(erreur) === null) throw erreur;
|
||||
return;
|
||||
}
|
||||
if (detruit) return;
|
||||
issue = rendue;
|
||||
await lireCorbeille();
|
||||
}
|
||||
|
||||
// La détermination se demande au montage, et de nouveau quand le refus de
|
||||
// la lecture se lève sans qu'elle soit connue : une détermination refusée
|
||||
// puis refaite se montre alors.
|
||||
let demande = null;
|
||||
function demander() {
|
||||
if (demande !== null) return;
|
||||
demande = prendreIssue(facade.demarrer()).finally(() => {
|
||||
demande = null;
|
||||
});
|
||||
}
|
||||
$effect(() => {
|
||||
if (refusLecture === null && issue === null) untrack(demander);
|
||||
});
|
||||
|
||||
// Après une opération qui change le dossier de travail.
|
||||
function apresOperation() {
|
||||
relireListe();
|
||||
lireCorbeille();
|
||||
}
|
||||
|
||||
// Les comptes d'un import depuis l'autre emplacement, ou de la copie que
|
||||
// fait la réponse « Documents ».
|
||||
const comptesImport = ({ importes, refuses }) => ({ importes: importes.length, refuses: refuses.length });
|
||||
|
||||
// Une réponse à la question : la détermination qu'elle rend devient celle
|
||||
// de l'accueil, et la façade d'App relit la liste quand plus rien
|
||||
// n'attend. Un refus se pose contre la réponse. Tant qu'une réponse
|
||||
// s'exécute — « Réessayer » refait la détermination —, la question ne
|
||||
// s'offre plus : la façade n'en a aucune à recevoir.
|
||||
let reponseEnCours = $state(false);
|
||||
async function repondre(choix) {
|
||||
reponseEnCours = true;
|
||||
let rendue;
|
||||
try {
|
||||
rendue = await surLaListe(() => facade.repondre(choix));
|
||||
} finally {
|
||||
reponseEnCours = false;
|
||||
}
|
||||
if (detruit) return;
|
||||
if (rendue.importe !== undefined) importAutre = comptesImport(rendue.importe);
|
||||
issue = rendue;
|
||||
await lireCorbeille();
|
||||
}
|
||||
|
||||
// L'import de l'autre emplacement ; la détermination relue ne le propose
|
||||
// plus.
|
||||
async function importerDepuisAutre() {
|
||||
importAutre = comptesImport(await surLaListe(() => facade.importerDepuisAutre()));
|
||||
await prendreIssue(facade.demarrer());
|
||||
}
|
||||
|
||||
async function importerFichier() {
|
||||
const importe = await surLaListe(() => facade.importerFichier());
|
||||
if (importe !== null) apresOperation();
|
||||
}
|
||||
|
||||
// Ouvre une ligne en lecture ; un état illisible mène à la récupération.
|
||||
async function ouvrir(ligne) {
|
||||
refusOuverture = null;
|
||||
try {
|
||||
await seance.ouvrir(ligne.base);
|
||||
} catch (erreur) {
|
||||
const refus = refusDeLaListe(erreur);
|
||||
if (refus === null) throw erreur;
|
||||
if (refus.code === 'ETAT_ILLISIBLE') recuperation = { base: ligne.base, chemin: ligne.chemin, refus };
|
||||
else refusOuverture = { base: ligne.base, refus };
|
||||
}
|
||||
}
|
||||
|
||||
async function charger(cle) {
|
||||
refusDemonstration = null;
|
||||
try {
|
||||
await surLaListe(() => facade.chargerDemonstration(cle));
|
||||
} catch (refus) {
|
||||
if (typeof refus?.code !== 'string') throw refus;
|
||||
refusDemonstration = { cle, refus };
|
||||
}
|
||||
}
|
||||
|
||||
async function creer(evenement) {
|
||||
evenement.preventDefault();
|
||||
refusCreation = null;
|
||||
try {
|
||||
await surLaListe(() => facade.creer({ nom, date }));
|
||||
} catch (refus) {
|
||||
if (typeof refus?.code !== 'string') throw refus;
|
||||
refusCreation = refus;
|
||||
return;
|
||||
}
|
||||
apresOperation();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if recuperation !== null}
|
||||
<Recuperation {...recuperation} retour={() => (recuperation = null)} />
|
||||
{:else}
|
||||
<section class="accueil" data-ecran="accueil">
|
||||
<h1>{t('coquille.liste')}</h1>
|
||||
<p class="version" data-version>
|
||||
<span>{t('coquille.version.libelle')}</span>
|
||||
<span>{versionVisible(provenance, version, t)}</span>
|
||||
</p>
|
||||
{#if refusLecture !== null}
|
||||
<p class="refus" data-refus="accueil">{t(refusLecture.code, refusLecture.details)}</p>
|
||||
{/if}
|
||||
|
||||
{#if issue !== null}
|
||||
<DossierTravail
|
||||
{issue}
|
||||
{racine}
|
||||
{avis}
|
||||
explorateur={facade.explorateur()}
|
||||
{repondre}
|
||||
{reponseEnCours}
|
||||
{importerDepuisAutre}
|
||||
{importAutre}
|
||||
lignesPresentes={lignes.length > 0}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ListeEvenements {lignes} {ouvrir} {refusOuverture} {apresOperation} />
|
||||
|
||||
<section class="creation" aria-labelledby={`${uid}-creation`}>
|
||||
<h2 id={`${uid}-creation`}>{t('accueil.creation.titre')}</h2>
|
||||
<form novalidate onsubmit={creer}>
|
||||
<div class="champ">
|
||||
<label for={`${uid}-nom`}>{t('accueil.creation.nom')}</label>
|
||||
<input
|
||||
id={`${uid}-nom`}
|
||||
type="text"
|
||||
data-champ="nom"
|
||||
bind:value={nom}
|
||||
aria-invalid={champRefuse === 'nom' ? 'true' : undefined}
|
||||
aria-describedby={champRefuse === 'nom' ? `${uid}-refus` : undefined}
|
||||
/>
|
||||
{#if champRefuse === 'nom'}
|
||||
<Refus id={`${uid}-refus`} refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="champ">
|
||||
<label for={`${uid}-date`}>{t('accueil.creation.date')}</label>
|
||||
<input
|
||||
id={`${uid}-date`}
|
||||
type="text"
|
||||
data-champ="date"
|
||||
bind:value={date}
|
||||
aria-invalid={champRefuse === 'date' ? 'true' : undefined}
|
||||
aria-describedby={champRefuse === 'date' ? `${uid}-refus` : undefined}
|
||||
/>
|
||||
{#if champRefuse === 'date'}
|
||||
<Refus id={`${uid}-refus`} refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="commandes">
|
||||
<button type="submit" data-commande="creer">{t('accueil.creation.creer')}</button>
|
||||
{#if refusCreation !== null && champRefuse === null}
|
||||
<Refus refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
<Commande cle="accueil.operation.importer" executer={importerFichier} />
|
||||
</section>
|
||||
|
||||
<Demonstrations {demonstrations} {charger} refus={refusDemonstration} />
|
||||
|
||||
<Corbeille contenu={corbeille} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.accueil {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
.version {
|
||||
display: flex;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.refus {
|
||||
color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
.creation,
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
input[aria-invalid='true'] {
|
||||
border-color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
71
src/interface/accueil/Corbeille.svelte
Normal file
71
src/interface/accueil/Corbeille.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La corbeille du dossier de travail (§ 8.7) : un événement supprimé y est
|
||||
// déplacé avec tous ses fichiers, dans un dossier daté, et le logiciel ne la
|
||||
// vide jamais. Chaque dossier daté montre son chemin en entier et le nom de
|
||||
// chacun de ses fichiers, des données dans leur propre nœud. Une corbeille
|
||||
// vide ne montre rien.
|
||||
//
|
||||
// contenu : [{ dossier, chemin, fichiers }] de facade.corbeille().
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
let { contenu } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const uid = $props.id();
|
||||
</script>
|
||||
|
||||
{#if contenu.length > 0}
|
||||
<section class="corbeille" data-corbeille aria-labelledby={`${uid}-titre`}>
|
||||
<h2 id={`${uid}-titre`}>{t('accueil.corbeille.titre')}</h2>
|
||||
<p>{t('accueil.corbeille.explication')}</p>
|
||||
<ul class="dossiers">
|
||||
{#each contenu as { dossier, chemin, fichiers } (dossier)}
|
||||
<li data-corbeille-dossier={dossier}>
|
||||
<p class="chemin">{chemin}</p>
|
||||
<ul class="fichiers">
|
||||
{#each fichiers as nom (nom)}
|
||||
<li>{nom}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.corbeille,
|
||||
.dossiers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
.dossiers {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chemin {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fichiers {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-4);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
</style>
|
||||
84
src/interface/accueil/Demonstrations.svelte
Normal file
84
src/interface/accueil/Demonstrations.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Les démonstrations (§ 15), une issue toujours offerte du premier écran :
|
||||
// une commande par démonstration reçue, dans leur ordre, qui porte son nom
|
||||
// — une donnée, dans son propre nœud — et data-demonstration, sa clé.
|
||||
// Charger crée un événement neuf, numéroté, ouvert en lecture ; aucun
|
||||
// autre n'est touché. Le refus d'un chargement s'affiche sous sa commande.
|
||||
//
|
||||
// demonstrations : [{ cle, nom }] ; charger(cle) : le geste de l'accueil ;
|
||||
// refus : { cle, refus } du dernier échec, ou null.
|
||||
import { getContext } from 'svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
||||
let { demonstrations, charger, refus } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const uid = $props.id();
|
||||
</script>
|
||||
|
||||
<section class="demonstrations" aria-labelledby={`${uid}-titre`}>
|
||||
<h2 id={`${uid}-titre`}>{t('accueil.demonstrations.titre')}</h2>
|
||||
<p>{t('accueil.demonstrations.explication')}</p>
|
||||
<ul>
|
||||
{#each demonstrations as { cle, nom } (cle)}
|
||||
<li>
|
||||
<button type="button" data-demonstration={cle} onclick={() => charger(cle)}>{nom}</button>
|
||||
{#if refus !== null && refus.cle === cle}
|
||||
<Refus refus={refus.refus} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.demonstrations,
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
140
src/interface/accueil/DossierTravail.svelte
Normal file
140
src/interface/accueil/DossierTravail.svelte
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le dossier de travail sur la liste (§ 8.6, § 8.8), dans cet ordre : la
|
||||
// question qui attend sa réponse — elle vient avant toute liste, et nomme
|
||||
// le dossier, la cause et ce qu'il porte ; la proposition d'importer
|
||||
// l'autre emplacement, quand le dossier retenu est neuf et vide — son
|
||||
// compte, puis son chemin dans son propre nœud (data-autre-chemin) —, avec
|
||||
// la phrase sur les originaux ; les avis de la détermination, non modaux ;
|
||||
// puis le chemin du dossier, en entier, et la commande qui l'ouvre dans
|
||||
// l'explorateur, ou, sous web, la phrase qui dit pourquoi elle manque.
|
||||
//
|
||||
// issue : la détermination { dossier, avis } de la façade ; racine : le
|
||||
// dossier de travail de la séance, null tant qu'une question attend ;
|
||||
// avis : ceux à dire, vides hors du premier accueil de la séance ;
|
||||
// explorateur : { disponible, raison } de la façade ; repondre(choix) et
|
||||
// importerDepuisAutre(), les gestes de l'accueil ; reponseEnCours : vrai
|
||||
// pendant une réponse, qui retire les réponses offertes ; importAutre : les
|
||||
// comptes du dernier import, ou null ; lignesPresentes : le dossier retenu
|
||||
// porte déjà des événements, et l'import de l'autre emplacement ne se
|
||||
// propose plus.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { reponsesDe, surLaListe } from './modele.js';
|
||||
|
||||
let {
|
||||
issue,
|
||||
racine,
|
||||
avis,
|
||||
explorateur,
|
||||
repondre,
|
||||
reponseEnCours,
|
||||
importerDepuisAutre,
|
||||
importAutre,
|
||||
lignesPresentes,
|
||||
} = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const facade = getContext('facade');
|
||||
const uid = $props.id();
|
||||
|
||||
const question = $derived(issue.dossier.question);
|
||||
const autre = $derived(issue.dossier.autre);
|
||||
const reponses = $derived(question === null ? [] : reponsesDe(question));
|
||||
</script>
|
||||
|
||||
<section class="dossier" data-dossier>
|
||||
{#if question !== null}
|
||||
<div class="question" role="group" data-question={question.code} aria-labelledby={`${uid}-question`}>
|
||||
<p id={`${uid}-question`}>{t(question.code, question)}</p>
|
||||
{#if !reponseEnCours}
|
||||
<div class="reponses">
|
||||
{#each reponses as { choix, cle } (choix)}
|
||||
<Commande {cle} executer={() => repondre(choix)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if autre !== null && !lignesPresentes}
|
||||
<div class="autre" data-autre>
|
||||
<p>{t('accueil.autre.proposition', { evenements: autre.evenements })}</p>
|
||||
<p class="donnee" data-autre-chemin>{autre.racine.chemin}</p>
|
||||
<p>{t('accueil.autre.originaux')}</p>
|
||||
<Commande cle="accueil.autre.importer" nom="importerAutre" executer={importerDepuisAutre} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if importAutre !== null}
|
||||
<p class="avis" role="status" data-avertissement="accueil.autre.resultat">{t('accueil.autre.resultat', importAutre)}</p>
|
||||
{/if}
|
||||
|
||||
{#each avis as { code, details }, rang (rang)}
|
||||
<p class="avis" role="status" data-avertissement={code}>{t(code, details)}</p>
|
||||
{/each}
|
||||
|
||||
{#if question === null && racine !== null}
|
||||
<div class="chemin">
|
||||
<h2 id={`${uid}-titre`}>{t('accueil.dossier.titre')}</h2>
|
||||
<p data-dossier-travail>{racine.chemin}</p>
|
||||
{#if explorateur.disponible}
|
||||
<Commande cle="accueil.dossier.explorateur" executer={() => surLaListe(() => facade.ouvrirExplorateur())} />
|
||||
{:else}
|
||||
<p class="avis" data-avertissement={explorateur.raison}>{t(explorateur.raison)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.dossier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
.question,
|
||||
.autre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-3);
|
||||
border: 2px solid var(--couleur-avis);
|
||||
border-radius: var(--rayon-2);
|
||||
}
|
||||
|
||||
.reponses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.avis {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
}
|
||||
|
||||
.chemin {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
}
|
||||
|
||||
[data-dossier-travail],
|
||||
.donnee {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
158
src/interface/accueil/ListeEvenements.svelte
Normal file
158
src/interface/accueil/ListeEvenements.svelte
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La liste des événements (§ 8.1) : une ligne par événement du dossier de
|
||||
// travail, dans l'ordre de la façade, que désigne data-evenement. Chaque
|
||||
// ligne porte la commande qui l'ouvre en lecture — son nom —, ses cinq
|
||||
// autres informations sous leur intitulé, chacune dans son propre nœud
|
||||
// (data-colonne), ses signalements traduits — les bases qu'un doublon
|
||||
// nomme suivent son texte, chacune dans son propre nœud (data-doublon) —,
|
||||
// le refus de son ouverture, et ses opérations. Un dossier sans événement n'affiche pas de liste vide :
|
||||
// la création et les démonstrations de l'accueil en tiennent lieu.
|
||||
//
|
||||
// lignes : LigneListe[] de la façade ; ouvrir(ligne) : le geste de
|
||||
// l'accueil ; refusOuverture : { base, refus } du dernier échec, ou null ;
|
||||
// apresOperation() : à appeler quand une opération a changé le dossier.
|
||||
import { getContext } from 'svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import Operations from './Operations.svelte';
|
||||
import { ligneAffichee } from './modele.js';
|
||||
|
||||
let { lignes, ouvrir, refusOuverture, apresOperation } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
</script>
|
||||
|
||||
{#if lignes.length > 0}
|
||||
<ul class="liste">
|
||||
{#each lignes as ligne (ligne.base)}
|
||||
{@const affichee = ligneAffichee(ligne, format)}
|
||||
<li class="ligne" data-evenement={ligne.base}>
|
||||
<button type="button" class="ouvrir" data-commande="ouvrir" onclick={() => ouvrir(ligne)}
|
||||
><span data-colonne="nom">{affichee.nom}</span></button
|
||||
>
|
||||
<dl class="informations">
|
||||
<div>
|
||||
<dt>{t('accueil.colonne.date')}</dt>
|
||||
<dd data-colonne="date">{affichee.date}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('accueil.colonne.etat')}</dt>
|
||||
<dd data-colonne="etat">{t(affichee.etat)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('accueil.colonne.participants')}</dt>
|
||||
<dd data-colonne="participants">{affichee.participants}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('accueil.colonne.modification')}</dt>
|
||||
<dd data-colonne="modification">{affichee.modification}</dd>
|
||||
</div>
|
||||
<div class="chemin">
|
||||
<dt>{t('accueil.colonne.chemin')}</dt>
|
||||
<dd data-colonne="chemin">{affichee.chemin}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#each affichee.signalements as { cle, details, noms }, rang (rang)}
|
||||
<div class="signalement" data-signalement={cle}>
|
||||
<p>{t(cle, details)}</p>
|
||||
{#if noms.length > 0}
|
||||
<ul class="noms">
|
||||
{#each noms as nom (nom)}
|
||||
<li data-doublon={nom}>{nom}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if refusOuverture !== null && refusOuverture.base === ligne.base}
|
||||
<Refus refus={refusOuverture.refus} />
|
||||
{/if}
|
||||
<Operations {ligne} {apresOperation} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ligne {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
}
|
||||
|
||||
.ouvrir {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-titre);
|
||||
font-weight: bold;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ouvrir:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.informations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.informations div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chemin dd {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.signalement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
}
|
||||
|
||||
.signalement p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.noms {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-4);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
152
src/interface/accueil/Operations.svelte
Normal file
152
src/interface/accueil/Operations.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Les opérations de fichiers d'une ligne de la liste (§ 8.7), leur seule
|
||||
// place : copier, renommer, supprimer. Copier et renommer demandent d'abord
|
||||
// un nom (saisieDe) : rien ne s'écrit avant la validation, et un nom vide
|
||||
// se refuse contre le champ. Copier ouvre la copie en lecture ; renommer
|
||||
// laisse la liste, que l'accueil relit. Supprimer déplace les fichiers dans
|
||||
// la corbeille et se confirme en place, par un second clic : aucune
|
||||
// fenêtre. Chaque refus s'affiche contre le champ ou à côté de sa
|
||||
// commande, sans remède (surLaListe) ; apresOperation() suit chaque
|
||||
// opération réussie.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { champDuRefus, saisieDe, surLaListe } from './modele.js';
|
||||
|
||||
let { ligne, apresOperation } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const facade = getContext('facade');
|
||||
const uid = $props.id();
|
||||
|
||||
// La saisie en cours, { operation, champ, etiquette, commande, valider,
|
||||
// texte }, ou null ; et son refus.
|
||||
let saisie = $state(null);
|
||||
let refus = $state.raw(null);
|
||||
const surLeChamp = $derived(champDuRefus(refus) === 'nom');
|
||||
|
||||
function ouvrirSaisie(operation) {
|
||||
refus = null;
|
||||
saisie = { operation, ...saisieDe(operation, ligne.nom) };
|
||||
}
|
||||
|
||||
function fermerSaisie() {
|
||||
saisie = null;
|
||||
refus = null;
|
||||
}
|
||||
|
||||
// Le geste de chaque saisie, par la façade.
|
||||
const GESTES = {
|
||||
copier: (nom) => facade.copier(ligne.base, { nom }),
|
||||
renommer: (nom) => facade.renommer(ligne.base, { nom }),
|
||||
};
|
||||
|
||||
async function valider(evenement) {
|
||||
evenement.preventDefault();
|
||||
refus = null;
|
||||
const { operation, texte } = saisie;
|
||||
try {
|
||||
await surLaListe(() => GESTES[operation](texte));
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
refus = erreur;
|
||||
return;
|
||||
}
|
||||
fermerSaisie();
|
||||
apresOperation();
|
||||
}
|
||||
|
||||
async function supprimer() {
|
||||
await surLaListe(() => facade.supprimer(ligne.base));
|
||||
apresOperation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="operations">
|
||||
{#if saisie === null}
|
||||
<Commande cle="accueil.operation.copier" executer={() => ouvrirSaisie('copier')} />
|
||||
<Commande cle="accueil.operation.renommer" executer={() => ouvrirSaisie('renommer')} />
|
||||
<Commande cle="accueil.operation.supprimer" confirmer="accueil.operation.confirmerSuppression" executer={supprimer} />
|
||||
{:else}
|
||||
<form novalidate onsubmit={valider}>
|
||||
<div class="champ">
|
||||
<label for={`${uid}-nom`}>{t(saisie.etiquette)}</label>
|
||||
<input
|
||||
id={`${uid}-nom`}
|
||||
type="text"
|
||||
data-champ={saisie.champ}
|
||||
bind:value={saisie.texte}
|
||||
aria-invalid={surLeChamp ? 'true' : undefined}
|
||||
aria-describedby={surLeChamp ? `${uid}-refus` : undefined}
|
||||
/>
|
||||
{#if surLeChamp}
|
||||
<Refus id={`${uid}-refus`} {refus} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="commandes">
|
||||
<button type="submit" data-commande={saisie.commande}>{t(saisie.valider)}</button>
|
||||
<button type="button" data-commande="annuler" onclick={fermerSaisie}>{t('accueil.operation.annuler')}</button>
|
||||
{#if refus !== null && !surLeChamp}
|
||||
<Refus {refus} />
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.operations,
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
form,
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.champ {
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 20em;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
input[aria-invalid='true'] {
|
||||
border-color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
81
src/interface/accueil/Recuperation.svelte
Normal file
81
src/interface/accueil/Recuperation.svelte
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La récupération d'un état qui ne s'ouvre pas (§ 8.8) : elle ne repart
|
||||
// jamais de zéro en silence. Elle nomme le fichier, en entier, et la raison
|
||||
// — le texte de l'ETAT_ILLISIBLE qu'a levé l'ouverture —, puis offre les
|
||||
// secours lisibles dans l'ordre du § 8.8, la version précédente puis le
|
||||
// dernier instant du journal (secoursOfferts), chacun ouvert en lecture
|
||||
// par seance.ouvrirSecours, sans rien écrire. Sans secours lisible, elle
|
||||
// le dit, et rien ne s'ouvre ni ne se crée. Le retour rend la liste.
|
||||
//
|
||||
// base, chemin : ceux de la ligne ; refus : { code, details } de
|
||||
// l'ouverture refusée ; retour() : rend la liste.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { secoursOfferts, surLaListe } from './modele.js';
|
||||
|
||||
let { base, chemin, refus, retour } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const uid = $props.id();
|
||||
|
||||
const offerts = $derived(secoursOfferts(refus.details.secours));
|
||||
</script>
|
||||
|
||||
<section class="recuperation" data-ecran="recuperation" aria-labelledby={`${uid}-titre`}>
|
||||
<h1 id={`${uid}-titre`}>{t('accueil.recuperation.titre')}</h1>
|
||||
<p class="fichier">
|
||||
<span>{t('accueil.recuperation.fichier')}</span>
|
||||
<span class="chemin">{chemin}</span>
|
||||
</p>
|
||||
<Refus {refus} />
|
||||
{#if offerts.length > 0}
|
||||
<p>{t('accueil.recuperation.offerte')}</p>
|
||||
<ul class="secours">
|
||||
{#each offerts as { source, cle } (source)}
|
||||
<li data-secours={source}>
|
||||
<Commande {cle} executer={() => surLaListe(() => seance.ouvrirSecours(base, source))} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p>{t('accueil.recuperation.aucunSecours')}</p>
|
||||
{/if}
|
||||
<Commande cle="accueil.recuperation.retour" executer={retour} />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.recuperation,
|
||||
.secours {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
h1,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fichier {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.chemin {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.secours {
|
||||
gap: var(--espace-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
272
src/interface/accueil/modele.js
Normal file
272
src/interface/accueil/modele.js
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'accueil, pur (§ 8.1, § 8.6, § 8.8) : ce que les composants
|
||||
// de la liste des événements affichent, sans arithmétique ni texte. Une
|
||||
// ligne de la liste (LigneListe de la façade, A3) devient ses six
|
||||
// informations mises en forme et ses signalements ; une question du dossier
|
||||
// de travail, ses réponses ; un état illisible, ses secours ; un refus de la
|
||||
// façade, le refus affiché sur la liste. Chaque clé rendue est écrite ici en
|
||||
// entier : aucun composant ne compose une clé.
|
||||
|
||||
// Les états du plan (§ 9) et la clé de leur nom, dans l'ordre du § 9.
|
||||
const CLES_ETATS = new Map([
|
||||
['brouillon', 'accueil.etat.brouillon'],
|
||||
['propose', 'accueil.etat.propose'],
|
||||
['retenu', 'accueil.etat.retenu'],
|
||||
['bloque', 'accueil.etat.bloque'],
|
||||
]);
|
||||
|
||||
/** Les états du plan, dans l'ordre du § 9. */
|
||||
export const ETATS = Object.freeze([...CLES_ETATS.keys()]);
|
||||
|
||||
// La clé de la valeur absente, celle de la mise en forme.
|
||||
const CLE_VIDE = 'format.vide';
|
||||
|
||||
// Les réponses de chaque question du dossier de travail, dans l'ordre où
|
||||
// l'accueil les offre, et la clé de leur libellé : celles qu'admet
|
||||
// facade.repondre (A3).
|
||||
const REPONSES = new Map([
|
||||
[
|
||||
'PORTABLE_NON_INSCRIPTIBLE',
|
||||
[
|
||||
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
|
||||
{ choix: 'documents', cle: 'accueil.question.documents' },
|
||||
],
|
||||
],
|
||||
[
|
||||
'DOCUMENTS_NON_INSCRIPTIBLE',
|
||||
[
|
||||
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
|
||||
{ choix: 'continuer', cle: 'accueil.question.continuer' },
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Les secours d'un état illisible, dans l'ordre du § 8.8 — la version
|
||||
// précédente, puis le dernier instant du journal —, et la clé de la commande
|
||||
// qui l'ouvre.
|
||||
const SECOURS = [
|
||||
{ source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' },
|
||||
{ source: 'journal', cle: 'communs.commande.ouvrirJournal' },
|
||||
];
|
||||
|
||||
/**
|
||||
* La clé du nom d'un état du plan ; null, celui d'un état illisible, rend
|
||||
* la clé de la valeur absente.
|
||||
*
|
||||
* @param {string|null} etat
|
||||
* @returns {string}
|
||||
* @throws {RangeError} un état hors de ETATS
|
||||
*/
|
||||
export function cleDEtat(etat) {
|
||||
if (etat === null) return CLE_VIDE;
|
||||
const cle = CLES_ETATS.get(etat);
|
||||
if (cle === undefined) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`);
|
||||
return cle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les signalements d'une ligne (§ 8.1, § 8.8), chacun { cle, details, noms }
|
||||
* : la traduction compose cle et details, et noms sont les données qu'il
|
||||
* nomme hors de son texte, que le composant rend chacune dans son propre
|
||||
* nœud (§ 14.6). ETAT_ILLISIBLE pour un état qui ne s'ouvre pas — un journal
|
||||
* sans état compris, de raison ABSENT —, avec la base, la raison et les
|
||||
* secours lus par la façade, et aucun nom à part ;
|
||||
* accueil.signalement.doublon pour un état dont d'autres bases portent
|
||||
* l'identifiant, avec leur nombre, et ces bases pour noms, dans l'ordre de la
|
||||
* façade.
|
||||
* Un verrou présent ne se signale pas : seule la sonde d'une opération dit si
|
||||
* sa séance vit. Les détails et les noms sont des copies.
|
||||
*
|
||||
* @param {Object} ligne LigneListe (A3)
|
||||
* @returns {Array<{cle: string, details: Object, noms: string[]}>}
|
||||
*/
|
||||
export function signalementsDe(ligne) {
|
||||
const signalements = [];
|
||||
if (ligne.illisible !== null) {
|
||||
signalements.push({ cle: 'ETAT_ILLISIBLE', details: { base: ligne.base, ...ligne.illisible }, noms: [] });
|
||||
}
|
||||
if (ligne.doublonAvec.length > 0) {
|
||||
const noms = [...ligne.doublonAvec];
|
||||
signalements.push({ cle: 'accueil.signalement.doublon', details: { autres: noms.length }, noms });
|
||||
}
|
||||
return signalements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les six informations d'une ligne (§ 8.1), mises en forme, et ses
|
||||
* signalements. nom : celui inscrit dans le fichier, ou la base quand l'état
|
||||
* ne se lit pas ; date, participants et dernière modification par format,
|
||||
* la valeur absente quand ils manquent — la dernière modification est
|
||||
* l'horodatage de la dernière entrée du journal, sur l'horloge de
|
||||
* l'application, jamais la date du système de fichiers (§ 19.4) ; etat : la
|
||||
* clé de son nom, que le composant traduit ; chemin : tel que la façade
|
||||
* l'affiche, en entier.
|
||||
*
|
||||
* @param {Object} ligne LigneListe (A3)
|
||||
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
|
||||
* @returns {{nom: string, date: string, etat: string, participants: string,
|
||||
* modification: string, chemin: string,
|
||||
* signalements: Array<{cle: string, details: Object, noms: string[]}>}}
|
||||
*/
|
||||
export function ligneAffichee(ligne, format) {
|
||||
return {
|
||||
nom: ligne.nom ?? ligne.base,
|
||||
date: ligne.date === null ? format.nombre(null) : format.date(ligne.date),
|
||||
etat: cleDEtat(ligne.etat),
|
||||
participants: format.nombre(ligne.participants),
|
||||
modification: ligne.derniereModification === null ? format.nombre(null) : format.horodatage(ligne.derniereModification),
|
||||
chemin: ligne.chemin,
|
||||
signalements: signalementsDe(ligne),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les réponses qu'offre une question du dossier de travail (§ 8.6), dans
|
||||
* l'ordre : [{ choix, cle }], choix tel que facade.repondre le reçoit.
|
||||
*
|
||||
* @param {{code: string}} question
|
||||
* @returns {Array<{choix: string, cle: string}>}
|
||||
* @throws {RangeError} une question inconnue
|
||||
*/
|
||||
export function reponsesDe(question) {
|
||||
const reponses = REPONSES.get(question.code);
|
||||
if (reponses === undefined) throw new RangeError(`question inconnue : ${JSON.stringify(question.code)}`);
|
||||
return reponses.map((reponse) => ({ ...reponse }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les secours lisibles d'un état illisible (§ 8.8), dans l'ordre : la
|
||||
* version précédente, puis le dernier instant du journal ; chacun { source,
|
||||
* cle }, source telle que seance.ouvrirSecours la reçoit. Aucun secours, ou
|
||||
* des secours non lus, rendent [].
|
||||
*
|
||||
* @param {{precedent: number|null, journal: number|null}|null|undefined} secours
|
||||
* @returns {Array<{source: 'precedent'|'journal', cle: string}>}
|
||||
*/
|
||||
export function secoursOfferts(secours) {
|
||||
if (secours === null || secours === undefined) return [];
|
||||
return SECOURS.filter(({ source }) => secours[source] !== null && secours[source] !== undefined).map((offert) => ({
|
||||
...offert,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le refus d'un geste de la liste, tel que l'accueil l'affiche : le code et
|
||||
* les détails de l'erreur, son remède retiré. Sur la liste, aucun événement
|
||||
* n'est ouvert : un remède de la séance — débloquer, modifier, ouvrir un
|
||||
* secours — n'y aurait rien sur quoi agir, et le texte du refus dit déjà ce
|
||||
* qui le lève. Une erreur sans code n'est pas un refus : null.
|
||||
*
|
||||
* @param {unknown} erreur
|
||||
* @returns {{code: string, details: Object}|null}
|
||||
*/
|
||||
export function refusDeLaListe(erreur) {
|
||||
if (typeof erreur?.code !== 'string') return null;
|
||||
return { code: erreur.code, details: { ...(erreur.details ?? {}), remede: null } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute une opération de la liste et rend ce qu'elle rend. Un refus
|
||||
* remonte tel que la liste l'affiche (refusDeLaListe) : { code, details },
|
||||
* sans remède. Toute autre erreur est une faute ou une panne, et remonte
|
||||
* telle quelle.
|
||||
*
|
||||
* @template T
|
||||
* @param {() => T|Promise<T>} operation
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
export async function surLaListe(operation) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (erreur) {
|
||||
const refus = refusDeLaListe(erreur);
|
||||
if (refus === null) throw erreur;
|
||||
throw refus;
|
||||
}
|
||||
}
|
||||
|
||||
// Le champ que désigne chaque refus d'un nom ou d'une date saisis.
|
||||
const CHAMPS_DES_REFUS = new Map([
|
||||
['NOM_REQUIS', 'nom'],
|
||||
['DATE_INVALIDE', 'date'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Le champ contre lequel un refus se pose : 'nom' pour NOM_REQUIS, 'date'
|
||||
* pour DATE_INVALIDE ; null pour tout autre refus, qui se pose contre la
|
||||
* commande, et sans refus.
|
||||
*
|
||||
* @param {{code: string}|null} refus
|
||||
* @returns {'nom'|'date'|null}
|
||||
*/
|
||||
export function champDuRefus(refus) {
|
||||
if (refus === null) return null;
|
||||
return CHAMPS_DES_REFUS.get(refus.code) ?? null;
|
||||
}
|
||||
|
||||
// La saisie d'un nom de chaque opération qui en demande un (§ 8.7) : le
|
||||
// data-champ de l'entrée, la clé de son étiquette, le data-commande et la
|
||||
// clé du libellé de la commande qui valide.
|
||||
const CLES_SAISIES = new Map([
|
||||
[
|
||||
'copier',
|
||||
{
|
||||
champ: 'nomCopie',
|
||||
etiquette: 'accueil.operation.nomCopie',
|
||||
commande: 'validerCopie',
|
||||
valider: 'accueil.operation.validerCopie',
|
||||
},
|
||||
],
|
||||
[
|
||||
'renommer',
|
||||
{
|
||||
champ: 'nouveauNom',
|
||||
etiquette: 'accueil.operation.nouveauNom',
|
||||
commande: 'validerRenommage',
|
||||
valider: 'accueil.operation.validerRenommage',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/** Les opérations de la liste qui demandent un nom avant d'écrire. */
|
||||
export const SAISIES = Object.freeze([...CLES_SAISIES.keys()]);
|
||||
|
||||
/**
|
||||
* La saisie d'un nom pour une opération (§ 8.7) : son champ, ses clés, et le
|
||||
* texte de départ — vide pour copier, qui demande un nom neuf plutôt que d'en
|
||||
* empiler un « (copie) » ; le nom inscrit pour renommer, vide quand l'état
|
||||
* ne se lit pas.
|
||||
*
|
||||
* @param {'copier'|'renommer'} operation
|
||||
* @param {string|null} nom le nom inscrit dans le fichier
|
||||
* @returns {{champ: string, etiquette: string, commande: string, valider: string, texte: string}}
|
||||
* @throws {RangeError} une opération hors de SAISIES
|
||||
*/
|
||||
export function saisieDe(operation, nom) {
|
||||
const cles = CLES_SAISIES.get(operation);
|
||||
if (cles === undefined) throw new RangeError(`opération sans saisie : ${JSON.stringify(operation)}`);
|
||||
return { ...cles, texte: operation === 'renommer' ? (nom ?? '') : '' };
|
||||
}
|
||||
|
||||
// Les séances dont le dossier de travail a déjà été annoncé.
|
||||
const ANNONCEES = new WeakSet();
|
||||
|
||||
/**
|
||||
* Vrai la première fois qu'on le demande pour une séance, faux ensuite
|
||||
* (§ 8.6) : l'accueil annonce le dossier de travail — les avis de la
|
||||
* détermination — une fois par séance, et non à chaque retour à la liste.
|
||||
* La séance est un objet qui vit autant qu'elle, la façade d'App ; rien ne
|
||||
* survit à elle.
|
||||
*
|
||||
* @param {object} seance
|
||||
* @returns {boolean}
|
||||
* @throws {TypeError} une séance qui n'est pas un objet
|
||||
*/
|
||||
export function premiereAnnonce(seance) {
|
||||
if (seance === null || typeof seance !== 'object') throw new TypeError('premiereAnnonce : la séance est un objet');
|
||||
if (ANNONCEES.has(seance)) return false;
|
||||
ANNONCEES.add(seance);
|
||||
return true;
|
||||
}
|
||||
303
src/interface/accueil/modele.test.js
Normal file
303
src/interface/accueil/modele.test.js
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'accueil (§ 8.1, § 8.6, § 8.8) : les six informations d'une
|
||||
// ligne de la liste, mises en forme, et ses signalements ; les réponses
|
||||
// d'une question du dossier de travail ; les secours d'un état illisible ;
|
||||
// le refus d'un geste de la liste, sans remède ; l'annonce du dossier de
|
||||
// travail, une fois par séance. Chaque clé que le modèle rend existe dans la
|
||||
// traduction. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import {
|
||||
ETATS,
|
||||
SAISIES,
|
||||
champDuRefus,
|
||||
cleDEtat,
|
||||
ligneAffichee,
|
||||
premiereAnnonce,
|
||||
refusDeLaListe,
|
||||
reponsesDe,
|
||||
saisieDe,
|
||||
secoursOfferts,
|
||||
signalementsDe,
|
||||
surLaListe,
|
||||
} from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const VIDE = t('format.vide');
|
||||
|
||||
const RACINE = 'C:\\Epreuve\\Tablees';
|
||||
|
||||
// Une ligne de la liste telle que la façade la rend (LigneListe, A3), lisible.
|
||||
function ligne(champs = {}) {
|
||||
return {
|
||||
base: 'Bal des Grisailles',
|
||||
id: 'evt-grisailles',
|
||||
nom: 'Bal des Grisailles',
|
||||
date: '2031-05-17',
|
||||
etat: 'propose',
|
||||
participants: 1234,
|
||||
derniereModification: '2031-05-17T08:05:00-04:00',
|
||||
chemin: `${RACINE}\\Bal des Grisailles.gtt.json`,
|
||||
illisible: null,
|
||||
journalSeul: false,
|
||||
doublonAvec: [],
|
||||
verrou: null,
|
||||
...champs,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ligneAffichee : les six informations (§ 8.1)', () => {
|
||||
test('une ligne lisible : nom, date, état, participants, dernière modification et chemin, mis en forme', () => {
|
||||
const affichee = ligneAffichee(ligne(), format);
|
||||
assert.deepEqual(Object.keys(affichee).sort(), [
|
||||
'chemin',
|
||||
'date',
|
||||
'etat',
|
||||
'modification',
|
||||
'nom',
|
||||
'participants',
|
||||
'signalements',
|
||||
]);
|
||||
assert.equal(affichee.nom, 'Bal des Grisailles');
|
||||
assert.equal(affichee.date, format.date('2031-05-17'));
|
||||
assert.equal(affichee.etat, 'accueil.etat.propose');
|
||||
assert.equal(affichee.participants, format.nombre(1234));
|
||||
assert.equal(affichee.modification, format.horodatage('2031-05-17T08:05:00-04:00'));
|
||||
assert.equal(affichee.chemin, `${RACINE}\\Bal des Grisailles.gtt.json`);
|
||||
assert.deepEqual(affichee.signalements, []);
|
||||
});
|
||||
|
||||
test('sans journal, la dernière modification est la valeur absente ; sans date, de même — jamais une date du système', () => {
|
||||
const affichee = ligneAffichee(ligne({ derniereModification: null, date: null }), format);
|
||||
assert.equal(affichee.modification, VIDE);
|
||||
assert.equal(affichee.date, VIDE);
|
||||
});
|
||||
|
||||
test('une ligne illisible : son nom est sa base, ses informations absentes, son état la valeur absente', () => {
|
||||
const affichee = ligneAffichee(
|
||||
ligne({
|
||||
nom: null,
|
||||
date: null,
|
||||
etat: null,
|
||||
participants: null,
|
||||
derniereModification: null,
|
||||
illisible: { base: 'Bal des Grisailles', raison: 'VIDE', chemin: null, secours: { precedent: null, journal: null } },
|
||||
}),
|
||||
format,
|
||||
);
|
||||
assert.equal(affichee.nom, 'Bal des Grisailles');
|
||||
assert.equal(affichee.date, VIDE);
|
||||
assert.equal(affichee.etat, 'format.vide');
|
||||
assert.equal(affichee.participants, VIDE);
|
||||
assert.equal(affichee.modification, VIDE);
|
||||
});
|
||||
|
||||
test('chaque état du plan a sa clé, qui existe ; un état inconnu lève', () => {
|
||||
assert.deepEqual([...ETATS], ['brouillon', 'propose', 'retenu', 'bloque']);
|
||||
for (const etat of ETATS) {
|
||||
const cle = cleDEtat(etat);
|
||||
assert.equal(cle, `accueil.etat.${etat}`);
|
||||
assert.equal(typeof t(cle), 'string');
|
||||
}
|
||||
assert.equal(cleDEtat(null), 'format.vide');
|
||||
assert.throws(() => cleDEtat('archive'), RangeError);
|
||||
});
|
||||
|
||||
test('ligneAffichee ne lit pas la ligne au-delà de ses champs : deux appels rendent la même chose', () => {
|
||||
assert.deepEqual(ligneAffichee(ligne(), format), ligneAffichee(ligne(), format));
|
||||
});
|
||||
});
|
||||
|
||||
describe('signalementsDe (§ 8.1, § 8.8)', () => {
|
||||
test('un état vide : ETAT_ILLISIBLE, sa base et sa raison', () => {
|
||||
const illisible = { base: 'Soirée vide', raison: 'VIDE', chemin: null, secours: { precedent: null, journal: null } };
|
||||
const signalements = signalementsDe(ligne({ base: 'Soirée vide', nom: null, illisible }));
|
||||
assert.deepEqual(signalements, [{ cle: 'ETAT_ILLISIBLE', details: illisible, noms: [] }]);
|
||||
assert.match(t(signalements[0].cle, signalements[0].details), /vide/);
|
||||
});
|
||||
|
||||
test('un journal sans état : ETAT_ILLISIBLE de raison ABSENT, qui nomme le secours du journal', () => {
|
||||
const illisible = { base: 'Soirée des Hiboux', raison: 'ABSENT', chemin: null, secours: { precedent: null, journal: 1 } };
|
||||
const [signalement, ...reste] = signalementsDe(ligne({ base: 'Soirée des Hiboux', journalSeul: true, illisible }));
|
||||
assert.deepEqual(reste, []);
|
||||
assert.equal(signalement.cle, 'ETAT_ILLISIBLE');
|
||||
assert.equal(signalement.details.raison, 'ABSENT');
|
||||
assert.match(t(signalement.cle, signalement.details), /historique/);
|
||||
});
|
||||
|
||||
test('un doublon nomme chaque autre base à part, hors de son texte, et sa clé existe', () => {
|
||||
const signalements = signalementsDe(ligne({ base: 'Merles A', doublonAvec: ['Merles B'] }));
|
||||
assert.deepEqual(signalements, [{ cle: 'accueil.signalement.doublon', details: { autres: 1 }, noms: ['Merles B'] }]);
|
||||
// Une base est une donnée : elle occupe son propre nœud, jamais le texte.
|
||||
assert.ok(!t(signalements[0].cle, signalements[0].details).includes('Merles'));
|
||||
const deux = signalementsDe(ligne({ doublonAvec: ['Merles B', 'Merles C'] }));
|
||||
assert.deepEqual(deux[0], { cle: 'accueil.signalement.doublon', details: { autres: 2 }, noms: ['Merles B', 'Merles C'] });
|
||||
assert.ok(!t(deux[0].cle, deux[0].details).includes('Merles'));
|
||||
});
|
||||
|
||||
test('une ligne lisible sans doublon ne porte rien ; un verrou présent ne se signale pas', () => {
|
||||
assert.deepEqual(signalementsDe(ligne()), []);
|
||||
assert.deepEqual(signalementsDe(ligne({ verrou: { seance: 'seance-autre', depuis: null } })), []);
|
||||
});
|
||||
|
||||
test('les détails rendus sont des copies : la ligne reçue ne change pas', () => {
|
||||
const recue = ligne({ doublonAvec: ['Merles B'] });
|
||||
const [signalement] = signalementsDe(recue);
|
||||
signalement.noms.push('Merles Z');
|
||||
assert.deepEqual(recue.doublonAvec, ['Merles B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reponsesDe : les réponses d’une question du dossier de travail (§ 8.6)', () => {
|
||||
test('PORTABLE_NON_INSCRIPTIBLE : réessayer, puis travailler dans les Documents', () => {
|
||||
const reponses = reponsesDe({ code: 'PORTABLE_NON_INSCRIPTIBLE' });
|
||||
assert.deepEqual(reponses, [
|
||||
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
|
||||
{ choix: 'documents', cle: 'accueil.question.documents' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('DOCUMENTS_NON_INSCRIPTIBLE : réessayer, puis continuer', () => {
|
||||
const reponses = reponsesDe({ code: 'DOCUMENTS_NON_INSCRIPTIBLE' });
|
||||
assert.deepEqual(reponses, [
|
||||
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
|
||||
{ choix: 'continuer', cle: 'accueil.question.continuer' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('chaque clé de réponse existe ; une question inconnue lève', () => {
|
||||
let comptees = 0;
|
||||
for (const code of ['PORTABLE_NON_INSCRIPTIBLE', 'DOCUMENTS_NON_INSCRIPTIBLE']) {
|
||||
for (const { cle } of reponsesDe({ code })) {
|
||||
comptees += 1;
|
||||
assert.equal(typeof t(cle), 'string');
|
||||
}
|
||||
}
|
||||
assert.equal(comptees, 4);
|
||||
assert.throws(() => reponsesDe({ code: 'AUTRE' }), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('secoursOfferts : les secours d’un état illisible (§ 8.8)', () => {
|
||||
test('la version précédente, puis le dernier instant du journal, dans cet ordre', () => {
|
||||
assert.deepEqual(secoursOfferts({ precedent: 4, journal: 6 }), [
|
||||
{ source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' },
|
||||
{ source: 'journal', cle: 'communs.commande.ouvrirJournal' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('seuls les secours lisibles ; aucun, ou des secours inconnus, ne rendent rien', () => {
|
||||
assert.deepEqual(secoursOfferts({ precedent: null, journal: 1 }), [
|
||||
{ source: 'journal', cle: 'communs.commande.ouvrirJournal' },
|
||||
]);
|
||||
assert.deepEqual(secoursOfferts({ precedent: 2, journal: null }), [
|
||||
{ source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' },
|
||||
]);
|
||||
assert.deepEqual(secoursOfferts({ precedent: null, journal: null }), []);
|
||||
assert.deepEqual(secoursOfferts(null), []);
|
||||
assert.deepEqual(secoursOfferts(undefined), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusDeLaListe : le refus d’un geste de la liste', () => {
|
||||
test('code et détails gardés, le remède retiré : aucun événement n’est ouvert pour l’exécuter', () => {
|
||||
const erreur = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } };
|
||||
const refus = refusDeLaListe(erreur);
|
||||
assert.deepEqual(refus, { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: null } });
|
||||
assert.deepEqual(erreur.details.remede, { geste: 'debloquer' });
|
||||
assert.equal(t(refus.code, refus.details), t(erreur.code, erreur.details));
|
||||
});
|
||||
|
||||
test('un refus sans détails en reçoit de vides ; une erreur sans code n’est pas un refus', () => {
|
||||
assert.deepEqual(refusDeLaListe({ code: 'NOM_REQUIS' }), { code: 'NOM_REQUIS', details: { remede: null } });
|
||||
assert.equal(refusDeLaListe(new TypeError('faute')), null);
|
||||
assert.equal(refusDeLaListe(null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('premiereAnnonce : le dossier de travail s’annonce une fois par séance (§ 8.6)', () => {
|
||||
test('vrai la première fois pour une séance, faux ensuite ; une autre séance a sa première fois', () => {
|
||||
const seance = {};
|
||||
const autre = {};
|
||||
assert.equal(premiereAnnonce(seance), true);
|
||||
assert.equal(premiereAnnonce(seance), false);
|
||||
assert.equal(premiereAnnonce(seance), false);
|
||||
assert.equal(premiereAnnonce(autre), true);
|
||||
assert.equal(premiereAnnonce(autre), false);
|
||||
});
|
||||
|
||||
test('une séance absente lève', () => {
|
||||
assert.throws(() => premiereAnnonce(null), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('surLaListe : une opération de la liste et son refus', () => {
|
||||
test('rend ce que rend l’opération', async () => {
|
||||
assert.deepEqual(await surLaListe(async () => ({ base: 'Bal des Grisailles' })), { base: 'Bal des Grisailles' });
|
||||
assert.equal(await surLaListe(() => null), null);
|
||||
});
|
||||
|
||||
test('un refus remonte sans remède ; toute autre erreur remonte telle quelle', async () => {
|
||||
const refus = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } };
|
||||
await assert.rejects(
|
||||
surLaListe(async () => {
|
||||
throw refus;
|
||||
}),
|
||||
(recu) => {
|
||||
assert.deepEqual(recu, { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: null } });
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const faute = new TypeError('faute');
|
||||
await assert.rejects(
|
||||
surLaListe(() => {
|
||||
throw faute;
|
||||
}),
|
||||
(recu) => recu === faute,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('champDuRefus : le champ contre lequel un refus se pose', () => {
|
||||
test('le nom pour NOM_REQUIS, la date pour DATE_INVALIDE, aucun sinon', () => {
|
||||
assert.equal(champDuRefus({ code: 'NOM_REQUIS', details: {} }), 'nom');
|
||||
assert.equal(champDuRefus({ code: 'DATE_INVALIDE', details: { date: '2031-02-30' } }), 'date');
|
||||
assert.equal(champDuRefus({ code: 'CHEMIN_TROP_LONG', details: {} }), null);
|
||||
assert.equal(champDuRefus(null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saisieDe : la saisie d’un nom pour copier ou renommer (§ 8.7)', () => {
|
||||
test('copier part d’un champ vide, renommer du nom inscrit ; chaque clé existe', () => {
|
||||
assert.deepEqual([...SAISIES], ['copier', 'renommer']);
|
||||
const copier = saisieDe('copier', 'Bal des Grisailles');
|
||||
assert.deepEqual(copier, {
|
||||
champ: 'nomCopie',
|
||||
etiquette: 'accueil.operation.nomCopie',
|
||||
commande: 'validerCopie',
|
||||
valider: 'accueil.operation.validerCopie',
|
||||
texte: '',
|
||||
});
|
||||
const renommer = saisieDe('renommer', 'Bal des Grisailles');
|
||||
assert.deepEqual(renommer, {
|
||||
champ: 'nouveauNom',
|
||||
etiquette: 'accueil.operation.nouveauNom',
|
||||
commande: 'validerRenommage',
|
||||
valider: 'accueil.operation.validerRenommage',
|
||||
texte: 'Bal des Grisailles',
|
||||
});
|
||||
assert.equal(saisieDe('renommer', null).texte, '');
|
||||
for (const saisie of [copier, renommer]) {
|
||||
assert.equal(typeof t(saisie.etiquette), 'string');
|
||||
assert.equal(typeof t(saisie.valider), 'string');
|
||||
}
|
||||
assert.throws(() => saisieDe('supprimer', 'x'), RangeError);
|
||||
});
|
||||
});
|
||||
61
src/interface/apropos.js
Normal file
61
src/interface/apropos.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le panneau « À propos » et la version à l'écran (§ 18.6). Le bloc tient en
|
||||
// une ligne, que l'opérateur copie dans un courriel : il porte les deux
|
||||
// formes de la version, l'affichée et celle que montrent les propriétés du
|
||||
// fichier sous Windows, que scripts/version.js engendre. Sa composition est
|
||||
// une seule clé de la table de la coquille : la langue témoin l'encadre une
|
||||
// fois, en entier.
|
||||
import { aUnLibelle } from './libelles.js';
|
||||
|
||||
/**
|
||||
* Bloc d'une ligne du panneau « À propos » (§ 18.6) : nom, version affichée,
|
||||
* version Windows, plateforme, provenance, version de format, séparés par
|
||||
* « · ». La provenance s'y lit sous son libellé, ou sous son propre nom
|
||||
* quand le mode n'en a pas ; la plateforme sous sa nature.
|
||||
*
|
||||
* @param {Object} champs
|
||||
* @param {{affichee: string, windows: string}} champs.version VERSION
|
||||
* @param {string} champs.plateforme la nature du système de fichiers
|
||||
* @param {string} champs.provenance le mode de construction de Vite
|
||||
* @param {number} champs.format la version de format des fichiers
|
||||
* @param {(cle: string, details?: Object) => string} t
|
||||
* @returns {string} sans saut de ligne
|
||||
*/
|
||||
export function blocAPropos({ version, plateforme, provenance, format }, t) {
|
||||
return t('coquille.apropos.bloc', {
|
||||
affichee: version.affichee,
|
||||
windows: version.windows,
|
||||
plateforme,
|
||||
provenance,
|
||||
format,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qui tient lieu de version à l'écran : la marque de provenance dans une
|
||||
* construction de documentation, dont les captures d'écran ne changent pas
|
||||
* d'une livraison à l'autre (§ 19.4) ; la version affichée sinon.
|
||||
*
|
||||
* @param {string} provenance le mode de construction de Vite
|
||||
* @param {string} version la version affichée
|
||||
* @param {(cle: string) => string} t
|
||||
* @returns {string}
|
||||
*/
|
||||
export function versionVisible(provenance, version, t) {
|
||||
return provenance === 'documentation' ? t('coquille.version.documentation') : version;
|
||||
}
|
||||
|
||||
/**
|
||||
* La provenance à l'écran : le libellé du mode de construction par la
|
||||
* traduction reçue, ou le mode lui-même quand il n'en a pas — celui d'un
|
||||
* « vite build » lancé sans --mode —, qui s'affiche alors comme une donnée.
|
||||
*
|
||||
* @param {string} provenance le mode de construction de Vite
|
||||
* @param {(cle: string) => string} t
|
||||
* @returns {string}
|
||||
*/
|
||||
export function provenanceVisible(provenance, t) {
|
||||
return aUnLibelle(provenance) ? t(`coquille.provenance.${provenance}`) : provenance;
|
||||
}
|
||||
89
src/interface/apropos.test.js
Normal file
89
src/interface/apropos.test.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le bloc « À propos » (§ 18.6) : une ligne que l'opérateur copie dans un
|
||||
// courriel — nom, version affichée, version Windows, plateforme, provenance,
|
||||
// version de format —, et ce qui tient lieu de version à l'écran, la marque
|
||||
// de provenance dans une construction de documentation (§ 19.4).
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../application/libelles.js';
|
||||
import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js';
|
||||
import { FORMAT } from '../stockage/document.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
|
||||
import { TABLES_INTERFACE, libelleProvenance } from './libelles.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const temoin = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' }).t;
|
||||
|
||||
describe('blocAPropos (§ 18.6)', () => {
|
||||
test('une ligne : nom, version affichée, version Windows, plateforme, provenance, format, séparés par « · »', () => {
|
||||
assert.match(VERSION.windows ?? '', /^\d+\.\d+\.\d+\.0$/, 'VERSION.windows engendré');
|
||||
const bloc = blocAPropos({ version: VERSION, plateforme: 'electron', provenance: 'livraison', format: FORMAT }, t);
|
||||
assert.equal(typeof bloc, 'string');
|
||||
assert.doesNotMatch(bloc, /[\n\r\u{2028}\u{2029}]/u);
|
||||
assert.deepEqual(bloc.split(' \u{00B7} '), [
|
||||
t('coquille.nom'),
|
||||
VERSION.affichee,
|
||||
VERSION.windows,
|
||||
'electron',
|
||||
libelleProvenance('livraison'),
|
||||
t('coquille.apropos.format', { format: FORMAT }),
|
||||
]);
|
||||
assert.ok(bloc.includes(String(FORMAT)));
|
||||
});
|
||||
|
||||
test('chaque plateforme et chaque provenance s’y lisent ; un mode sans libellé sous son propre nom', () => {
|
||||
const cas = [
|
||||
['web', 'developpement', libelleProvenance('developpement')],
|
||||
['epreuve', 'test', libelleProvenance('test')],
|
||||
['electron', 'documentation', libelleProvenance('documentation')],
|
||||
['web', 'production', 'production'],
|
||||
];
|
||||
assert.ok(cas.length > 0);
|
||||
for (const [plateforme, provenance, libelle] of cas) {
|
||||
const champs = blocAPropos({ version: VERSION, plateforme, provenance, format: FORMAT }, t).split(' \u{00B7} ');
|
||||
assert.equal(champs[3], plateforme, provenance);
|
||||
assert.equal(champs[4], libelle, provenance);
|
||||
}
|
||||
});
|
||||
|
||||
test('en langue témoin, le bloc entier est encadré une fois', () => {
|
||||
const bloc = blocAPropos({ version: VERSION, plateforme: 'web', provenance: 'test', format: FORMAT }, temoin);
|
||||
const { ouverture, fermeture } = MARQUE_TEMOIN;
|
||||
assert.ok(bloc.startsWith(ouverture) && bloc.endsWith(fermeture), bloc);
|
||||
assert.equal(bloc.split(ouverture).length, 2, bloc);
|
||||
assert.equal(bloc.split(fermeture).length, 2, bloc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('versionVisible (§ 18.6, § 19.4)', () => {
|
||||
test('la version affichée, hors d’une construction de documentation', () => {
|
||||
for (const provenance of ['developpement', 'livraison', 'test']) {
|
||||
assert.equal(versionVisible(provenance, VERSION.affichee, t), VERSION.affichee, provenance);
|
||||
}
|
||||
});
|
||||
|
||||
test('la marque de provenance, sans le numéro, dans une construction de documentation', () => {
|
||||
const marque = versionVisible('documentation', VERSION.affichee, t);
|
||||
assert.equal(marque, t('coquille.version.documentation'));
|
||||
assert.ok(!marque.includes(VERSION.affichee), marque);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provenanceVisible (§ 18.6)', () => {
|
||||
test('le libellé du mode par la traduction reçue ; un mode sans libellé sous son propre nom', () => {
|
||||
for (const mode of ['developpement', 'livraison', 'documentation', 'test']) {
|
||||
assert.equal(provenanceVisible(mode, t), libelleProvenance(mode), mode);
|
||||
}
|
||||
assert.equal(provenanceVisible('production', t), 'production');
|
||||
assert.equal(provenanceVisible('constructor', t), 'constructor');
|
||||
});
|
||||
|
||||
test('en langue témoin, le libellé est encadré, le mode sans libellé non : il est une donnée', () => {
|
||||
const { ouverture, fermeture } = MARQUE_TEMOIN;
|
||||
assert.equal(provenanceVisible('test', temoin), `${ouverture}${libelleProvenance('test')}${fermeture}`);
|
||||
assert.equal(provenanceVisible('production', temoin), 'production');
|
||||
});
|
||||
});
|
||||
364
src/interface/diagnostic/Diagnostic.navigateur.test.js
Normal file
364
src/interface/diagnostic/Diagnostic.navigateur.test.js
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le diagnostic dans un vrai moteur de rendu (§ 2.1 étape 8, § 5.4, § 5.5,
|
||||
// § 5.6, § 7.5, § 8.4, § 15.1, § 15.3, § 15.4, § 19.3 capture 10) : le
|
||||
// panneau compact du plan, lisible dès l'ouverture sans aucun clic ; la
|
||||
// section complète, vivante en lecture et sur un plan bloqué ; le remède qui
|
||||
// désactive la séparation, exécuté en écriture et refusé en lecture ; une
|
||||
// configuration que le moteur refuse ; le bandeau du plan qui énonce le
|
||||
// diagnostic quand toutes les tables sont en conflit. Chaque épreuve monte
|
||||
// App par monterApp, au cadre de 1 366 × 700, et lit le rendu : le texte,
|
||||
// la boîte, les attributs data-… — jamais une classe.
|
||||
//
|
||||
// Un énoncé porte data-enonce (sa clé), son texte data-phrase, une
|
||||
// appartenance data-donnee, un remède data-remede. La valeur attendue vient
|
||||
// de diagnostiquer, sollicité une seconde fois sur la configuration de la
|
||||
// charge ouverte (§ 19.1) ; le texte, de la même traduction que celle de
|
||||
// l'application. Les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { diagnostiquer } from '../../moteur/diagnostic.js';
|
||||
import { ErreurConfiguration } from '../../moteur/erreurs.js';
|
||||
import { normaliser } from '../../moteur/configuration.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { visible } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { enonces, numeroDeCharge } from './phrases.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// --- Les charges d'épreuve -------------------------------------------------------
|
||||
|
||||
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
// Douze personnes inventées, quatre tables de 3, quatre tours ; appartenance
|
||||
// donne celle de la personne de rang p.
|
||||
function chargeDouze(appartenance) {
|
||||
const charge = creerCharge({ id: 'evt-diagnostic', nom: 'Soirée des Merles', siegesParDefaut: 3, tours: 4 });
|
||||
charge.participants = Array.from({ length: 12 }, (_, p) => ({
|
||||
id: p + 1,
|
||||
nom: `Pervenche${p + 1}`,
|
||||
prenom: 'Théo',
|
||||
appartenance: appartenance(p),
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La petite démonstration où quatre personnes portent une réservation de
|
||||
// portée « tous les tours », sans siège, à la table 1, de 3 places : le
|
||||
// stockage l'admet, le moteur la refuse (SURRESERVATION).
|
||||
function quatreReserveesSurTrois() {
|
||||
const charge = chargeDemo('petite');
|
||||
charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
return charge;
|
||||
}
|
||||
|
||||
// --- Le montage --------------------------------------------------------------------
|
||||
|
||||
let montes = [];
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
afterEach(() => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function monter(options) {
|
||||
const m = await monterApp(options);
|
||||
montes.push(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
// La démonstration de clé donnée, chargée par la façade et ouverte en
|
||||
// lecture, sur la section qu'ouvre un événement.
|
||||
async function demonstration(cle) {
|
||||
const m = await monter();
|
||||
await m.facade.chargerDemonstration(cle);
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
// La charge posée en fichier d'état sous base, ouverte en lecture.
|
||||
async function ouvrirTexte(texte, base = 'soiree') {
|
||||
const m = await monter({ fichiers: [{ racine: 'documents', chemin: `${base}${SUFFIXES.etat}`, texte }] });
|
||||
await m.seance.ouvrir(base);
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
const ouvrirCharge = (charge) => ouvrirTexte(serialiser(charge, { revision: 1, produitVersion: V1 }));
|
||||
|
||||
// --- Les lectures du rendu ---------------------------------------------------------
|
||||
|
||||
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
|
||||
const section = (m) => dans(m, '[data-section="diagnostic"]');
|
||||
const panneau = (m) => dans(m, '[data-panneau-diagnostic]');
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const charge = (m) => m.seance.etat().charge;
|
||||
const revision = (m) => m.seance.etat().evenement.revision;
|
||||
|
||||
// Chaque énoncé rendu sous la racine : sa clé, son appartenance, son texte,
|
||||
// et son remède, null quand il n'en porte pas.
|
||||
function lus(racine) {
|
||||
return [...racine.querySelectorAll('[data-enonce]')].map((li) => {
|
||||
const donnee = li.querySelector('[data-donnee]');
|
||||
const remede = li.querySelector('[data-remede]');
|
||||
return {
|
||||
cle: li.dataset.enonce,
|
||||
groupe: donnee === null ? null : texteDe(donnee),
|
||||
phrase: texteDe(li.querySelector('[data-phrase]')),
|
||||
remede: remede === null ? null : texteDe(remede),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Les énoncés attendus pour la charge, du moteur sollicité une seconde fois ;
|
||||
// avecRemede : la forme complète, qui écrit le texte du remède.
|
||||
function attendus(chargeLue, { avecRemede }) {
|
||||
const diagnostic = diagnostiquer(configurationDepuisCharge(chargeLue));
|
||||
const liste = enonces(diagnostic, { numeroDe: numeroDeCharge(chargeLue), tours: chargeLue.evenement.tours });
|
||||
return liste.map(({ cle, details, remede }) => ({
|
||||
cle,
|
||||
groupe: details.groupe ?? null,
|
||||
phrase: t(cle, details),
|
||||
remede: avecRemede && remede !== null ? t(remede.cle, remede.details) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Les épreuves ------------------------------------------------------------------
|
||||
|
||||
describe('le diagnostic se lit sans rien demander (§ 2.1 étape 8, § 5.6)', () => {
|
||||
test('la petite au conflit inévitable, sans proposition, ouverte : le panneau du plan l\u{2019}énonce, dans la fenêtre ; une table ajoutée le change sans autre geste', async () => {
|
||||
const m = await demonstration('petite-conflit');
|
||||
expect(m.ui.etat().section).toBe('plan');
|
||||
expect(charge(m).propositions.length).toBe(0);
|
||||
const avant = attendus(charge(m), { avecRemede: false });
|
||||
expect(avant.length).toBeGreaterThan(0);
|
||||
expect(avant.map(({ cle }) => cle)).toContain('diagnostic.collisions');
|
||||
await attendre(() => expect(lus(panneau(m))).toEqual(avant));
|
||||
const premier = panneau(m).querySelector('[data-enonce]');
|
||||
expect(visible(premier)).toBe(true);
|
||||
expect(visible(premier.querySelector('[data-phrase]'))).toBe(true);
|
||||
// Compact : aucun remède, aucune commande dans le panneau.
|
||||
expect(panneau(m).querySelectorAll('[data-remede], [data-commande]').length).toBe(0);
|
||||
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('creerTable', { forme: 'ronde' });
|
||||
const apres = attendus(charge(m), { avecRemede: false });
|
||||
expect(apres).not.toEqual(avant);
|
||||
expect(apres.map(({ cle }) => cle)).not.toContain('diagnostic.collisions');
|
||||
await attendre(() => expect(lus(panneau(m))).toEqual(apres));
|
||||
});
|
||||
});
|
||||
|
||||
describe('vivant en lecture (§ 8.4)', () => {
|
||||
test('en lecture, la section se lit, remèdes compris, sans rien écrire ; sur un plan bloqué, de même', async () => {
|
||||
const m = await demonstration('petite-conflit');
|
||||
const ecrituresAvant = m.trace.length;
|
||||
m.ui.allerA('diagnostic');
|
||||
flushSync();
|
||||
expect(m.seance.etat().mode).toBe('lecture');
|
||||
const attendue = attendus(charge(m), { avecRemede: true });
|
||||
expect(attendue.filter(({ remede }) => remede !== null).length).toBeGreaterThan(0);
|
||||
await attendre(() => expect(lus(section(m))).toEqual(attendue));
|
||||
expect(visible(section(m).querySelector('[data-phrase]'))).toBe(true);
|
||||
expect(m.trace.length).toBe(ecrituresAvant);
|
||||
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
expect(m.seance.etat().refus.code).toBe('LECTURE_SEULE');
|
||||
m.ui.allerA('diagnostic');
|
||||
await attendre(() => expect(lus(section(m))).toEqual(attendus(charge(m), { avecRemede: true })));
|
||||
m.ui.allerA('plan');
|
||||
await attendre(() => expect(lus(panneau(m))).toEqual(attendus(charge(m), { avecRemede: false })));
|
||||
});
|
||||
});
|
||||
|
||||
describe('la grande démonstration (§ 15.1, § 19.3 capture 10)', () => {
|
||||
test('les valeurs affichées égalent celles de diagnostiquer : 24, 99, le plancher de l\u{2019}écart d\u{2019}itinéraire', async () => {
|
||||
const m = await demonstration('grande');
|
||||
m.ui.allerA('diagnostic');
|
||||
const diagnostic = diagnostiquer(configurationDepuisCharge(charge(m)));
|
||||
expect(diagnostic.plafondAPriori.repartition[0]).toEqual({ valeur: 24, ancres: 4, mobiles: 0 });
|
||||
expect(diagnostic.retoursImposes).toBe(99);
|
||||
expect(diagnostic.ecartItineraire).not.toBeNull();
|
||||
const attendue = attendus(charge(m), { avecRemede: true });
|
||||
await attendre(() => expect(lus(section(m))).toEqual(attendue));
|
||||
const phrases = lus(section(m)).map(({ phrase }) => phrase);
|
||||
expect(phrases).toContain(t('diagnostic.plafond', diagnostic.plafondAPriori.repartition[0]));
|
||||
expect(phrases).toContain(
|
||||
t('diagnostic.imposes', { retoursImposes: diagnostic.retoursImposes, ancragesImposantRetours: diagnostic.ancragesImposantRetours }),
|
||||
);
|
||||
expect(phrases).toContain(t('diagnostic.ecartItineraire', diagnostic.ecartItineraire));
|
||||
expect(phrases.join(' ')).toMatch(/\b24 rencontres\b/);
|
||||
expect(phrases.join(' ')).toMatch(/\b99 retours imposés\b/);
|
||||
expect(phrases.join(' ')).toMatch(new RegExp(`plancher ${diagnostic.ecartItineraire.plancher},`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('une seule appartenance (§ 15.4)', () => {
|
||||
test('la phrase et son remède ; en lecture, refusé avec « Modifier » ; en écriture, exécuté en une entrée', async () => {
|
||||
const m = await ouvrirCharge(chargeDouze(() => 'Club des Merles'));
|
||||
m.ui.allerA('diagnostic');
|
||||
flushSync();
|
||||
const [premier] = lus(section(m));
|
||||
expect(premier).toEqual({
|
||||
cle: 'diagnostic.uneSeuleAppartenance',
|
||||
groupe: null,
|
||||
phrase: t('diagnostic.uneSeuleAppartenance'),
|
||||
remede: t('diagnostic.remede.separation'),
|
||||
});
|
||||
const ligne = section(m).querySelector('[data-enonce="diagnostic.uneSeuleAppartenance"]');
|
||||
const bouton = ligne.querySelector('[data-commande="modifierReglage"]');
|
||||
expect(bouton).not.toBeNull();
|
||||
expect(texteDe(bouton)).toBe(t('communs.remede.modifierReglage'));
|
||||
expect(visible(bouton)).toBe(true);
|
||||
|
||||
// En lecture : refusé, le refus posé à côté, avec « Modifier » ; rien d'écrit.
|
||||
const ecrituresAvant = m.trace.length;
|
||||
const revisionAvant = revision(m);
|
||||
bouton.click();
|
||||
const refus = await attendre(() => {
|
||||
const trouve = ligne.querySelector('[data-refus="MODE_LECTURE"]');
|
||||
expect(trouve).not.toBeNull();
|
||||
return trouve;
|
||||
});
|
||||
const modifier = refus.querySelector('[data-commande="modifier"]');
|
||||
expect(modifier).not.toBeNull();
|
||||
expect(texteDe(modifier)).toBe(t('communs.remede.modifier'));
|
||||
expect(revision(m)).toBe(revisionAvant);
|
||||
expect(m.trace.length).toBe(ecrituresAvant);
|
||||
|
||||
// « Modifier » passe en écriture ; le remède s'exécute, une entrée.
|
||||
modifier.click();
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||||
const revisionEcriture = revision(m);
|
||||
ligne.querySelector('[data-commande="modifierReglage"]').click();
|
||||
await attendre(() => expect(charge(m).reglages.separerAppartenances).toBe(false));
|
||||
expect(revision(m)).toBe(revisionEcriture + 1);
|
||||
await attendre(() => expect(section(m).querySelector('[data-enonce="diagnostic.uneSeuleAppartenance"]')).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('la configuration refusée (§ 5.6, § 5.9)', () => {
|
||||
test('quatre réservées sur une table de 3 : le code traduit, aucun énoncé, aucun message brut ; les autres sections vivent', async () => {
|
||||
const chargeRefusee = quatreReserveesSurTrois();
|
||||
let refusMoteur = null;
|
||||
try {
|
||||
normaliser(configurationDepuisCharge(chargeRefusee));
|
||||
} catch (erreur) {
|
||||
expect(erreur).toBeInstanceOf(ErreurConfiguration);
|
||||
refusMoteur = erreur;
|
||||
}
|
||||
expect(refusMoteur?.code).toBe('SURRESERVATION');
|
||||
const m = await ouvrirCharge(chargeRefusee);
|
||||
const message = t('SURRESERVATION', refusMoteur.details);
|
||||
|
||||
// Le panneau du plan, ouvert d'emblée.
|
||||
await attendre(() => expect(panneau(m).querySelector('[data-refus="SURRESERVATION"]')).not.toBeNull());
|
||||
for (const racine of [panneau(m)]) {
|
||||
expect(texteDe(racine.querySelector('[data-refus="SURRESERVATION"]'))).toBe(message);
|
||||
expect(racine.querySelectorAll('[data-enonce]').length).toBe(0);
|
||||
expect(racine.textContent).not.toContain(refusMoteur.message);
|
||||
}
|
||||
|
||||
// La section.
|
||||
m.ui.allerA('diagnostic');
|
||||
await attendre(() => expect(section(m)).not.toBeNull());
|
||||
const porteur = section(m).querySelector('[data-refus="SURRESERVATION"]');
|
||||
expect(texteDe(porteur)).toBe(message);
|
||||
expect(visible(porteur)).toBe(true);
|
||||
expect(texteDe(section(m))).toContain(t('diagnostic.nonCalcule'));
|
||||
expect(section(m).querySelectorAll('[data-enonce]').length).toBe(0);
|
||||
expect(section(m).textContent).not.toContain(refusMoteur.message);
|
||||
|
||||
// Les participants se lisent toujours.
|
||||
m.ui.allerA('participants');
|
||||
await attendre(() => expect(m.racine.querySelectorAll('[data-section="participants"] [data-participant]').length).toBe(12));
|
||||
});
|
||||
});
|
||||
|
||||
describe('le bandeau du plan énonce le diagnostic (§ 7.5)', () => {
|
||||
// Les entrées attendues du bandeau, du moteur sollicité une seconde fois :
|
||||
// la phrase, collisionPartout, puis chaque plancher de groupe.
|
||||
function bandeauAttendu(chargeLue) {
|
||||
const diagnostic = diagnostiquer(configurationDepuisCharge(chargeLue));
|
||||
expect(diagnostic.collisionPartout).toBe(true);
|
||||
expect(diagnostic.collisions.length).toBe(2);
|
||||
return [
|
||||
t('plan.bandeau.toutesEnConflit'),
|
||||
t('plan.bandeau.collisionPartout'),
|
||||
...diagnostic.collisions.map(
|
||||
({ groupe, effectif, plancherCumulees }) => `${groupe} ${t('plan.bandeau.plancherGroupe', { groupe, effectif, plancherCumulees })}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const bandeau = (m) => dans(m, '[data-bandeau-plan]');
|
||||
// Chaque entrée, ses nœuds — l'appartenance, puis le texte — joints par
|
||||
// une espace.
|
||||
const entrees = (m) => [...bandeau(m).querySelectorAll('li')].map((li) => [...li.children].map(texteDe).join(' '));
|
||||
|
||||
test('toutes en conflit, la proposition montrée : la phrase, collisionPartout, puis les planchers de chaque groupe', async () => {
|
||||
const m = await ouvrirTexte(TEXTE_TOUTES_EN_CONFLIT, 'toutes_en_conflit');
|
||||
m.ui.montrerProposition(1);
|
||||
const attendu = bandeauAttendu(charge(m));
|
||||
await attendre(() => expect(bandeau(m)).not.toBeNull());
|
||||
await attendre(() => expect(entrees(m)).toEqual(attendu));
|
||||
expect(visible(bandeau(m).querySelector('li'))).toBe(true);
|
||||
// Les chiffres du moteur, au chiffre près, dans le rendu.
|
||||
const { collisions } = diagnostiquer(configurationDepuisCharge(charge(m)));
|
||||
for (const { groupe, plancherCumulees } of collisions) {
|
||||
const ligne = [...bandeau(m).querySelectorAll('li')].find((li) => li.textContent.startsWith(groupe));
|
||||
expect(ligne.textContent).toContain(`${plancherCumulees} collisions`);
|
||||
}
|
||||
});
|
||||
|
||||
test('une table rendue saine au tour montré : ni phrase ni plancher', async () => {
|
||||
// La personne 8, à la table 1 du tour 1 avec 4 et 7, prend une troisième
|
||||
// appartenance : la table 1 réunit trois appartenances à ce tour.
|
||||
const lue = analyser(TEXTE_TOUTES_EN_CONFLIT).charge;
|
||||
lue.participants.find(({ id }) => id === 8).appartenance = 'Grisaille';
|
||||
expect(lue.propositions[0].placement[0].sieges[0]).toEqual([4, 7, 8]);
|
||||
const m = await ouvrirCharge(lue);
|
||||
m.ui.montrerProposition(1);
|
||||
await attendre(() => expect(m.racine.querySelectorAll('[data-plan] [data-table]').length).toBe(4));
|
||||
await attendre(() => expect(bandeau(m)).toBeNull());
|
||||
expect(m.racine.textContent).not.toContain(t('plan.bandeau.toutesEnConflit'));
|
||||
expect(m.racine.querySelector('[data-surface]').textContent).not.toContain(t('plan.bandeau.collisionPartout'));
|
||||
});
|
||||
});
|
||||
150
src/interface/diagnostic/Diagnostic.svelte
Normal file
150
src/interface/diagnostic/Diagnostic.svelte
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le diagnostic de la configuration (§ 2.1 étape 8, § 5.6) : ce que la
|
||||
// configuration rend inévitable, combien, et ce qui le rendrait évitable.
|
||||
// Il se calcule sur la charge de l'état observable à chaque charge écrite,
|
||||
// par diagnostiquerCharge, et s'énonce par phrases.js : le composant ne
|
||||
// fait que traduire. Regarder n'écrit jamais (§ 8.4) : il se lit en
|
||||
// lecture comme sur un plan bloqué.
|
||||
//
|
||||
// compact : la forme du panneau latéral du plan, que SectionPlan monte
|
||||
// dans [data-panneau-diagnostic] — son titre et les énoncés seuls. Sinon, la section,
|
||||
// [data-section="diagnostic"] : chaque énoncé suivi du texte de son
|
||||
// remède et, quand une commande le réalise, de la Commande qui l'exécute
|
||||
// par la séance ; un refus de la séance — mode lecture, plan bloqué —
|
||||
// s'affiche à côté du bouton, avec son propre remède.
|
||||
//
|
||||
// Une configuration que le moteur refuse n'a pas de diagnostic : son
|
||||
// code, traduit, se pose à sa place, jamais le message de l'erreur.
|
||||
// L'appartenance d'un groupe est une donnée : elle occupe son propre
|
||||
// nœud, avant le texte.
|
||||
import { getContext } from 'svelte';
|
||||
import { diagnostiquerCharge } from '../../application/diagnostic.js';
|
||||
import { actionRemede } from '../primitives/commande.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { enonces, numeroDeCharge } from './phrases.js';
|
||||
|
||||
let { compact = false } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui') ?? null;
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const resultat = $derived(charge === null ? null : diagnostiquerCharge(charge));
|
||||
const liste = $derived(
|
||||
resultat === null || resultat.diagnostic === null
|
||||
? []
|
||||
: enonces(resultat.diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }),
|
||||
);
|
||||
|
||||
// L'action d'un remède que réalise une commande ; null pour un remède de
|
||||
// texte seul.
|
||||
const actionDe = (remede) => (remede?.commande === undefined ? null : actionRemede(remede, { seance, ui }));
|
||||
</script>
|
||||
|
||||
{#snippet contenu()}
|
||||
{#if resultat !== null && resultat.erreur !== null}
|
||||
<p>{t('diagnostic.nonCalcule')}</p>
|
||||
<Refus refus={resultat.erreur} />
|
||||
{:else if liste.length > 0}
|
||||
<ul aria-label={t('diagnostic.enonces')}>
|
||||
{#each liste as enonce, rang (rang)}
|
||||
<li data-enonce={enonce.cle}>
|
||||
<p class="enonce">
|
||||
{#if enonce.details.groupe !== undefined}<span class="donnee" data-donnee>{enonce.details.groupe}</span>{/if}<span
|
||||
data-phrase>{t(enonce.cle, enonce.details)}</span
|
||||
>
|
||||
</p>
|
||||
{#if !compact && enonce.remede !== null}
|
||||
{@const action = actionDe(enonce.remede)}
|
||||
<p class="remede" data-remede>{t(enonce.remede.cle, enonce.remede.details)}</p>
|
||||
{#if action !== null}
|
||||
<Commande
|
||||
cle={action.cle}
|
||||
details={action.details}
|
||||
nom={action.nom}
|
||||
confirmer={action.confirmer}
|
||||
executer={action.executer}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if compact}
|
||||
<div class="diagnostic compact">
|
||||
<h3>{t('coquille.section.diagnostic')}</h3>
|
||||
{@render contenu()}
|
||||
</div>
|
||||
{:else}
|
||||
<section class="diagnostic" data-section="diagnostic">
|
||||
<h2>{t('coquille.section.diagnostic')}</h2>
|
||||
{@render contenu()}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.diagnostic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.compact ul {
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.enonce {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.donnee {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.remede {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
</style>
|
||||
192
src/interface/diagnostic/phrases.js
Normal file
192
src/interface/diagnostic/phrases.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les énoncés du diagnostic (§ 5.6) : ce que la configuration rend
|
||||
// inévitable, combien, et ce qui le rendrait évitable. Chaque énoncé est
|
||||
// {cle, details, remede}, que l'écran traduit ; ses détails sont les
|
||||
// chiffres du diagnostic tels que le moteur les rend — aucun ne se calcule
|
||||
// ici —, plus le numéro affiché d'une table, que donne numeroDe, et le
|
||||
// nombre de tours de l'événement, une donnée de la charge.
|
||||
//
|
||||
// remede : null, quand l'énoncé ne dit qu'une absence ; {cle, details}, le
|
||||
// texte de ce qui lèverait l'inévitable ; {cle, details, commande,
|
||||
// arguments} quand une commande le réalise sans rien détruire, exécutable
|
||||
// telle quelle par seance.executer, comme le remède d'un refus. Retirer des
|
||||
// tours détruit les réservations d'un tour retiré : le remède en tours reste
|
||||
// un texte.
|
||||
//
|
||||
// L'ordre est celui du § 5.6 — collisions et excédent, redondance,
|
||||
// animateurs, ancrage, écart d'itinéraire, manque de places —, précédé de ce
|
||||
// qui vide la séparation de son sens (§ 15.4, § 7.5) et suivi du plafond a
|
||||
// priori (§ 5.5) et des retours imposés (§ 5.4). Un plancher ne s'énonce que
|
||||
// strictement positif : un plancher nul laisserait croire qu'un plancher a
|
||||
// été calculé là où il n'y en a pas.
|
||||
|
||||
// Désactiver la séparation : le seul remède que réalise une commande. Un
|
||||
// objet neuf à chaque appel.
|
||||
const separer = () => ({
|
||||
cle: 'diagnostic.remede.separation',
|
||||
details: {},
|
||||
commande: 'modifierReglage',
|
||||
arguments: { cle: 'separerAppartenances', valeur: false },
|
||||
});
|
||||
|
||||
// Un remède neuf de la clé donnée.
|
||||
const remede = (cle, details = {}) => ({ cle, details });
|
||||
|
||||
// La séparation des appartenances vidée de son sens : une seule
|
||||
// appartenance, ou une collision forcée à chaque table de chaque tour.
|
||||
function separation({ uneSeuleAppartenance, collisionPartout }) {
|
||||
const liste = [];
|
||||
if (uneSeuleAppartenance) liste.push({ cle: 'diagnostic.uneSeuleAppartenance', details: {}, remede: separer() });
|
||||
if (collisionPartout) liste.push({ cle: 'diagnostic.collisionPartout', details: {}, remede: separer() });
|
||||
return liste;
|
||||
}
|
||||
|
||||
// Les tables à ajouter que rend le moteur, ou, quand aucune ne suffit, la
|
||||
// redistribution de l'appartenance.
|
||||
const remedeCollisions = ({ tablesManquantes }) =>
|
||||
tablesManquantes === null ? remede('diagnostic.remede.redistribuer') : remede('diagnostic.remede.tables', { tablesManquantes });
|
||||
|
||||
// Chaque groupe en collision : son plancher de collisions cumulées et de
|
||||
// paires distinctes, puis son excédent quand il est strictement positif.
|
||||
function collisions(diagnostic, tours) {
|
||||
return diagnostic.collisions.flatMap((ligne) => {
|
||||
const { groupe, effectif, plancherCumulees, plancherPairesDistinctes, plancherExcedent } = ligne;
|
||||
const liste = [
|
||||
{
|
||||
cle: 'diagnostic.collisions',
|
||||
details: { groupe, effectif, plancherCumulees, plancherPairesDistinctes, tours },
|
||||
remede: remedeCollisions(ligne),
|
||||
},
|
||||
];
|
||||
if (plancherExcedent !== null) {
|
||||
liste.push({ cle: 'diagnostic.excedent', details: { groupe, plancherExcedent }, remede: remedeCollisions(ligne) });
|
||||
}
|
||||
return liste;
|
||||
});
|
||||
}
|
||||
|
||||
// La redondance minimale, quand elle est positive, et le nombre de tours qui
|
||||
// l'annule ; sans lui, ce qui la fait tenir.
|
||||
function redondance({ redondanceMinimale, remedes }, tours) {
|
||||
if (redondanceMinimale === null || redondanceMinimale === 0) return [];
|
||||
const { toursSansRedondance } = remedes;
|
||||
return [
|
||||
{
|
||||
cle: 'diagnostic.redondance',
|
||||
details: { redondanceMinimale },
|
||||
remede:
|
||||
toursSansRedondance === null
|
||||
? remede('diagnostic.remede.appartenances')
|
||||
: remede('diagnostic.remede.tours', { toursSansRedondance, tours }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Les animateurs d'une même appartenance, quand ils imposent au moins une
|
||||
// redondance.
|
||||
function animateurs({ animateursMemeAppartenance, redondanceImposeeParAnimateurs }) {
|
||||
if (!animateursMemeAppartenance || redondanceImposeeParAnimateurs === 0) return [];
|
||||
return [
|
||||
{
|
||||
cle: 'diagnostic.animateurs',
|
||||
details: { redondanceImposeeParAnimateurs },
|
||||
remede: remede('diagnostic.remede.animateurs'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Chaque table gelée ou à moitié ancrée, dans l'ordre du diagnostic,
|
||||
// désignée par son numéro affiché.
|
||||
function ancrage(diagnostic, numeroDe) {
|
||||
return diagnostic.ancrage
|
||||
.filter(({ gelee, moitie }) => gelee || moitie)
|
||||
.map(({ table, ancres, capacite, gelee }) => ({
|
||||
cle: gelee ? 'diagnostic.ancrage.gelee' : 'diagnostic.ancrage.moitie',
|
||||
details: { numero: numeroDe(table), ancres, capacite },
|
||||
remede: remede('diagnostic.remede.ancrage'),
|
||||
}));
|
||||
}
|
||||
|
||||
// Le plancher de l'écart d'itinéraire maximal, là où le moteur le prouve.
|
||||
function ecartItineraire({ ecartItineraire: ecart }) {
|
||||
if (ecart === null) return [];
|
||||
return [
|
||||
{
|
||||
cle: 'diagnostic.ecartItineraire',
|
||||
details: { plancher: ecart.plancher, mobilesAuMoins: ecart.mobilesAuMoins },
|
||||
remede: remede('diagnostic.remede.ecartItineraire'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Le manque de places, quand il est positif.
|
||||
function placesManquantes({ placesManquantes: manquantes }) {
|
||||
if (manquantes === 0) return [];
|
||||
return [{ cle: 'diagnostic.placesManquantes', details: { placesManquantes: manquantes }, remede: remede('diagnostic.remede.places') }];
|
||||
}
|
||||
|
||||
// Le plafond a priori, une ligne par valeur de la répartition ; une
|
||||
// population vide n'en a aucun, et l'énoncé le dit par le tiret.
|
||||
function plafond({ plafondAPriori }) {
|
||||
if (plafondAPriori.repartition.length === 0) return [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }];
|
||||
return plafondAPriori.repartition.map(({ valeur, ancres, mobiles }) => ({
|
||||
cle: 'diagnostic.plafond',
|
||||
details: { valeur, ancres, mobiles },
|
||||
remede: remede('diagnostic.remede.plafond'),
|
||||
}));
|
||||
}
|
||||
|
||||
// Les retours que les réservations imposent, à part des retours choisis.
|
||||
function imposes({ retoursImposes, ancragesImposantRetours }) {
|
||||
if (retoursImposes === 0) return [];
|
||||
return [
|
||||
{
|
||||
cle: 'diagnostic.imposes',
|
||||
details: { retoursImposes, ancragesImposantRetours },
|
||||
remede: remede('diagnostic.remede.imposes'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Les énoncés d'un diagnostic, dans l'ordre décrit en tête. Ne modifie pas
|
||||
* le diagnostic.
|
||||
*
|
||||
* @param {Object} diagnostic celui que rend diagnostiquerCharge
|
||||
* @param {{numeroDe: (table: number) => number, tours: number}} options
|
||||
* numeroDe : le numéro affiché d'un identifiant de table ; tours : le
|
||||
* nombre de tours de l'événement
|
||||
* @returns {Array<{cle: string, details: Object, remede: null|{cle: string, details: Object,
|
||||
* commande?: string, arguments?: Object}}>}
|
||||
*/
|
||||
export function enonces(diagnostic, { numeroDe, tours }) {
|
||||
return [
|
||||
...separation(diagnostic),
|
||||
...collisions(diagnostic, tours),
|
||||
...redondance(diagnostic, tours),
|
||||
...animateurs(diagnostic),
|
||||
...ancrage(diagnostic, numeroDe),
|
||||
...ecartItineraire(diagnostic),
|
||||
...placesManquantes(diagnostic),
|
||||
...plafond(diagnostic),
|
||||
...imposes(diagnostic),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Le numéro affiché de chaque table de la charge, par identifiant.
|
||||
*
|
||||
* @param {{tables: Array<{id: number, numero: number}>}} charge
|
||||
* @returns {(table: number) => number} lève RangeError pour un identifiant
|
||||
* que la charge n'a pas
|
||||
*/
|
||||
export function numeroDeCharge(charge) {
|
||||
const numeros = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
|
||||
return (table) => {
|
||||
const numero = numeros.get(table);
|
||||
if (numero === undefined) throw new RangeError(`table inconnue : ${JSON.stringify(table)}`);
|
||||
return numero;
|
||||
};
|
||||
}
|
||||
494
src/interface/diagnostic/phrases.test.js
Normal file
494
src/interface/diagnostic/phrases.test.js
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des énoncés du diagnostic (§ 5.4, § 5.5, § 5.6, § 12.10.4,
|
||||
// § 15.1, § 15.3, § 15.4) : chaque énoncé porte les chiffres que rend
|
||||
// diagnostiquer, sollicité une seconde fois (§ 19.1), et la clé de son
|
||||
// remède ; le texte se lit par la même traduction que celle de
|
||||
// l'application. Les charges partent des démonstrations livrées ou se
|
||||
// construisent ici, sur des noms inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { diagnostiquerCharge } from '../../application/diagnostic.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { diagnostiquer } from '../../moteur/diagnostic.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { enonces, numeroDeCharge } from './phrases.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const TIRET = '\u{2014}';
|
||||
|
||||
// --- Les charges d'épreuve ---------------------------------------------------------
|
||||
|
||||
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
/**
|
||||
* Une charge construite : une personne par appartenance donnée (null : sans
|
||||
* appartenance), d'identifiants 1 à N ; une table par capacité, d'identifiant
|
||||
* 1 à T et de numéro numeros[rang] (rang + 1 par défaut) ; les réservations
|
||||
* de portée « tous les tours », sans siège, données en [participant, table].
|
||||
*/
|
||||
function chargeDe({ appartenances, capacites, tours = 4, ancres = [], separer = true, numeros = null }) {
|
||||
const charge = creerCharge({ id: 'evt-diagnostic', nom: 'Soirée des Merles', siegesParDefaut: 3, tours });
|
||||
charge.participants = appartenances.map((appartenance, rang) => ({
|
||||
id: rang + 1,
|
||||
nom: `Ombrelle${rang + 1}`,
|
||||
prenom: 'Iris',
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = capacites.map((sieges, rang) => ({
|
||||
id: rang + 1,
|
||||
numero: numeros === null ? rang + 1 : numeros[rang],
|
||||
sieges,
|
||||
forme: 'ronde',
|
||||
position: { x: 300 * rang, y: 0 },
|
||||
}));
|
||||
charge.reservations = ancres.map(([participant, table]) => ({ participant, table, siege: null, portee: 'tous', tour: null }));
|
||||
charge.reglages.separerAppartenances = separer;
|
||||
charge.prochainsIds = { participant: appartenances.length + 1, table: capacites.length + 1, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
const fois = (n, valeur) => Array(n).fill(valeur);
|
||||
|
||||
// Le diagnostic d'une charge, par l'application, et ses énoncés.
|
||||
function lire(charge) {
|
||||
const { diagnostic, erreur } = diagnostiquerCharge(charge);
|
||||
assert.equal(erreur, null);
|
||||
return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }) };
|
||||
}
|
||||
|
||||
const cles = (liste) => liste.map(({ cle }) => cle);
|
||||
const avecCle = (liste, cle) => liste.filter((enonce) => enonce.cle === cle);
|
||||
const texte = ({ cle, details }) => t(cle, details);
|
||||
|
||||
// Les configurations nommées de l'épreuve.
|
||||
const CONFIGURATIONS = {
|
||||
// 28 personnes en 18 appartenances, dix de 2 et huit de 1, quatre tables
|
||||
// de 7, quatre tours : la redondance du remède en tours (M1).
|
||||
redondance: () =>
|
||||
chargeDe({
|
||||
appartenances: [
|
||||
...Array.from({ length: 10 }, (_, g) => fois(2, `Cercle ${g + 1}`)).flat(),
|
||||
...Array.from({ length: 8 }, (_, g) => `Atelier ${g + 1}`),
|
||||
],
|
||||
capacites: fois(4, 7),
|
||||
}),
|
||||
// Quatre tables de 3, quatre tours, un animateur par table, tous du Club
|
||||
// des Merles ; les huit autres sans appartenance.
|
||||
animateurs: () =>
|
||||
chargeDe({
|
||||
appartenances: [...fois(4, 'Club des Merles'), ...fois(8, null)],
|
||||
capacites: fois(4, 3),
|
||||
ancres: [[1, 1], [2, 2], [3, 3], [4, 4]],
|
||||
}),
|
||||
// Table 1, numérotée 7 : 3 places, 3 ancrés ; table 2, numérotée 8 :
|
||||
// 4 places, 2 ancrés ; table 3, numérotée 9 : 4 places, aucun.
|
||||
ancrage: () =>
|
||||
chargeDe({
|
||||
appartenances: fois(10, null),
|
||||
capacites: [3, 4, 4],
|
||||
numeros: [7, 8, 9],
|
||||
ancres: [[1, 1], [2, 1], [3, 1], [4, 2], [5, 2]],
|
||||
}),
|
||||
// Trois membres d'une appartenance, deux tables de 2, quatre tours.
|
||||
excedent: () => chargeDe({ appartenances: fois(3, 'Grisaille'), capacites: [2, 2] }),
|
||||
// Deux membres réservés à la même table : aucune table ajoutée ne les
|
||||
// sépare.
|
||||
memeTable: () =>
|
||||
chargeDe({ appartenances: [...fois(2, 'Grisaille'), ...fois(4, null)], capacites: [3, 3], ancres: [[1, 1], [2, 1]] }),
|
||||
// Treize personnes, quatre tables de 3.
|
||||
manque: () => chargeDe({ appartenances: fois(13, null), capacites: fois(4, 3) }),
|
||||
// Douze personnes en deux appartenances de six, quatre tables de 3.
|
||||
toutesEnConflit: () =>
|
||||
chargeDe({
|
||||
appartenances: Array.from({ length: 12 }, (_, p) => (p % 2 === 0 ? 'Club des Merles' : 'Cercle des Hérons')),
|
||||
capacites: fois(4, 3),
|
||||
}),
|
||||
// Douze personnes d'une seule appartenance, séparation active.
|
||||
uneSeule: () => chargeDe({ appartenances: fois(12, 'Club des Merles'), capacites: fois(4, 3) }),
|
||||
// Huit personnes en deux appartenances de quatre, une table de 8, un tour.
|
||||
unTour: () => chargeDe({ appartenances: [...fois(4, 'Grisaille'), ...fois(4, 'Club des Merles')], capacites: [8], tours: 1 }),
|
||||
petite: () => chargeDemo('petite'),
|
||||
petiteConflit: () => chargeDemo('petite-conflit'),
|
||||
grande: () => chargeDemo('grande'),
|
||||
grandeSansException: () => chargeDemo('grande-sans-exception'),
|
||||
};
|
||||
|
||||
// --- Chaque champ, son énoncé ------------------------------------------------------
|
||||
|
||||
// Pour chaque champ du diagnostic : ce qui le rend non nul, et les énoncés
|
||||
// qu'il appelle alors — la clé et les détails exacts, tirés du diagnostic
|
||||
// même ; numero vient de numeroDe, tours de la charge.
|
||||
const SEPARATION = {
|
||||
cle: 'diagnostic.remede.separation',
|
||||
details: {},
|
||||
commande: 'modifierReglage',
|
||||
arguments: { cle: 'separerAppartenances', valeur: false },
|
||||
};
|
||||
const remedeCollisions = ({ tablesManquantes }) =>
|
||||
tablesManquantes === null
|
||||
? { cle: 'diagnostic.remede.redistribuer', details: {} }
|
||||
: { cle: 'diagnostic.remede.tables', details: { tablesManquantes } };
|
||||
|
||||
const CHAMPS = [
|
||||
{
|
||||
champ: 'uneSeuleAppartenance',
|
||||
attendus: (d) => (d.uneSeuleAppartenance ? [{ cle: 'diagnostic.uneSeuleAppartenance', details: {}, remede: SEPARATION }] : []),
|
||||
},
|
||||
{
|
||||
champ: 'collisionPartout',
|
||||
attendus: (d) => (d.collisionPartout ? [{ cle: 'diagnostic.collisionPartout', details: {}, remede: SEPARATION }] : []),
|
||||
},
|
||||
{
|
||||
champ: 'collisions',
|
||||
attendus: (d, tours) =>
|
||||
d.collisions.map((ligne) => ({
|
||||
cle: 'diagnostic.collisions',
|
||||
details: {
|
||||
groupe: ligne.groupe,
|
||||
effectif: ligne.effectif,
|
||||
plancherCumulees: ligne.plancherCumulees,
|
||||
plancherPairesDistinctes: ligne.plancherPairesDistinctes,
|
||||
tours,
|
||||
},
|
||||
remede: remedeCollisions(ligne),
|
||||
})),
|
||||
},
|
||||
{
|
||||
champ: 'collisions[].plancherExcedent',
|
||||
attendus: (d) =>
|
||||
d.collisions
|
||||
.filter(({ plancherExcedent }) => plancherExcedent !== null)
|
||||
.map((ligne) => ({
|
||||
cle: 'diagnostic.excedent',
|
||||
details: { groupe: ligne.groupe, plancherExcedent: ligne.plancherExcedent },
|
||||
remede: remedeCollisions(ligne),
|
||||
})),
|
||||
},
|
||||
{
|
||||
champ: 'redondanceMinimale',
|
||||
attendus: (d, tours) =>
|
||||
d.redondanceMinimale === null || d.redondanceMinimale === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
cle: 'diagnostic.redondance',
|
||||
details: { redondanceMinimale: d.redondanceMinimale },
|
||||
remede:
|
||||
d.remedes.toursSansRedondance === null
|
||||
? { cle: 'diagnostic.remede.appartenances', details: {} }
|
||||
: { cle: 'diagnostic.remede.tours', details: { toursSansRedondance: d.remedes.toursSansRedondance, tours } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
champ: 'animateursMemeAppartenance',
|
||||
attendus: (d) =>
|
||||
d.animateursMemeAppartenance && d.redondanceImposeeParAnimateurs > 0
|
||||
? [
|
||||
{
|
||||
cle: 'diagnostic.animateurs',
|
||||
details: { redondanceImposeeParAnimateurs: d.redondanceImposeeParAnimateurs },
|
||||
remede: { cle: 'diagnostic.remede.animateurs', details: {} },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
champ: 'ancrage',
|
||||
attendus: (d, _tours, numeroDe) =>
|
||||
d.ancrage
|
||||
.filter(({ gelee, moitie }) => gelee || moitie)
|
||||
.map(({ table, ancres, capacite, gelee }) => ({
|
||||
cle: gelee ? 'diagnostic.ancrage.gelee' : 'diagnostic.ancrage.moitie',
|
||||
details: { numero: numeroDe(table), ancres, capacite },
|
||||
remede: { cle: 'diagnostic.remede.ancrage', details: {} },
|
||||
})),
|
||||
},
|
||||
{
|
||||
champ: 'ecartItineraire',
|
||||
attendus: (d) =>
|
||||
d.ecartItineraire === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
cle: 'diagnostic.ecartItineraire',
|
||||
details: { plancher: d.ecartItineraire.plancher, mobilesAuMoins: d.ecartItineraire.mobilesAuMoins },
|
||||
remede: { cle: 'diagnostic.remede.ecartItineraire', details: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
champ: 'placesManquantes',
|
||||
attendus: (d) =>
|
||||
d.placesManquantes > 0
|
||||
? [
|
||||
{
|
||||
cle: 'diagnostic.placesManquantes',
|
||||
details: { placesManquantes: d.placesManquantes },
|
||||
remede: { cle: 'diagnostic.remede.places', details: {} },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
champ: 'plafondAPriori',
|
||||
attendus: (d) =>
|
||||
d.plafondAPriori.repartition.length === 0
|
||||
? [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }]
|
||||
: d.plafondAPriori.repartition.map(({ valeur, ancres, mobiles }) => ({
|
||||
cle: 'diagnostic.plafond',
|
||||
details: { valeur, ancres, mobiles },
|
||||
remede: { cle: 'diagnostic.remede.plafond', details: {} },
|
||||
})),
|
||||
},
|
||||
{
|
||||
champ: 'retoursImposes',
|
||||
attendus: (d) =>
|
||||
d.retoursImposes > 0
|
||||
? [
|
||||
{
|
||||
cle: 'diagnostic.imposes',
|
||||
details: { retoursImposes: d.retoursImposes, ancragesImposantRetours: d.ancragesImposantRetours },
|
||||
remede: { cle: 'diagnostic.remede.imposes', details: {} },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('enonces : chaque champ a son énoncé', () => {
|
||||
test('sur la suite des configurations, chaque champ non nul a ses énoncés, dans l\u{2019}ordre, et rien d\u{2019}autre', () => {
|
||||
const exerces = new Set();
|
||||
for (const [nom, construire] of Object.entries(CONFIGURATIONS)) {
|
||||
const charge = construire();
|
||||
const { diagnostic, liste } = lire(charge);
|
||||
const numeroDe = numeroDeCharge(charge);
|
||||
// Le moteur, sollicité une seconde fois, sur la même configuration.
|
||||
const moteur = diagnostiquer(configurationDepuisCharge(charge));
|
||||
const attendue = CHAMPS.flatMap(({ champ, attendus }) => {
|
||||
const lignes = attendus(moteur, charge.evenement.tours, numeroDe);
|
||||
if (lignes.length > 0 && !(champ === 'plafondAPriori' && moteur.plafondAPriori.repartition.length === 0)) {
|
||||
exerces.add(champ);
|
||||
}
|
||||
return lignes;
|
||||
});
|
||||
assert.ok(attendue.length > 0, nom);
|
||||
assert.deepEqual(liste, attendue, nom);
|
||||
assert.deepEqual(diagnostic, moteur, nom);
|
||||
for (const enonce of liste) {
|
||||
// Chaque texte existe ; chaque énoncé d'un champ non nul a un remède.
|
||||
assert.equal(typeof texte(enonce), 'string');
|
||||
if (enonce.cle !== 'diagnostic.plafondInconnu') assert.notEqual(enonce.remede, null, enonce.cle);
|
||||
if (enonce.remede !== null) assert.equal(typeof texte(enonce.remede), 'string');
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...exerces].sort(), CHAMPS.map(({ champ }) => champ).sort());
|
||||
});
|
||||
|
||||
test('collisionPartout vrai : l\u{2019}énoncé et son remède, exécutable par modifierReglage', () => {
|
||||
const { diagnostic, liste } = lire(CONFIGURATIONS.toutesEnConflit());
|
||||
assert.equal(diagnostic.collisionPartout, true);
|
||||
assert.deepEqual(avecCle(liste, 'diagnostic.collisionPartout'), [
|
||||
{
|
||||
cle: 'diagnostic.collisionPartout',
|
||||
details: {},
|
||||
remede: {
|
||||
cle: 'diagnostic.remede.separation',
|
||||
details: {},
|
||||
commande: 'modifierReglage',
|
||||
arguments: { cle: 'separerAppartenances', valeur: false },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enonces : les phrases du § 5.6', () => {
|
||||
test('collisions : la petite au conflit inévitable, le groupe de 5, ses deux planchers et leurs unités', () => {
|
||||
const charge = CONFIGURATIONS.petiteConflit();
|
||||
const { diagnostic, liste } = lire(charge);
|
||||
const [ligne] = diagnostic.collisions;
|
||||
assert.equal(diagnostic.collisions.length, 1);
|
||||
const [enonce] = avecCle(liste, 'diagnostic.collisions');
|
||||
assert.equal(enonce.details.effectif, 5);
|
||||
assert.equal(enonce.details.plancherCumulees, ligne.plancherCumulees);
|
||||
assert.equal(enonce.details.plancherPairesDistinctes, ligne.plancherPairesDistinctes);
|
||||
assert.equal(enonce.details.groupe, ligne.groupe);
|
||||
const phrase = texte(enonce);
|
||||
assert.ok(phrase.includes('au moins 4 collisions cumulées sur les 4 tours, portées par au moins 1 paire distincte'), phrase);
|
||||
assert.ok(phrase.includes('5 personnes'), phrase);
|
||||
assert.ok(!phrase.includes(ligne.groupe), 'l\u{2019}appartenance occupe son propre nœud');
|
||||
assert.doesNotMatch(phrase, /\b4 paires?\b/);
|
||||
// Chaque chiffre nomme son unité.
|
||||
const nombres = [...phrase.matchAll(/\d+/g)];
|
||||
assert.equal(nombres.length, 4);
|
||||
for (const nombre of nombres) {
|
||||
assert.match(phrase.slice(nombre.index), /^\d+ (personnes?|collisions? cumulées?|tours?|paires? distinctes?)\b/, phrase);
|
||||
}
|
||||
// 4 ≤ C(5, 2) : aucun excédent.
|
||||
assert.equal(ligne.plancherExcedent, null);
|
||||
assert.equal(avecCle(liste, 'diagnostic.excedent').length, 0);
|
||||
assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.tables', details: { tablesManquantes: 1 } });
|
||||
assert.ok(texte(enonce.remede).includes('1 table'));
|
||||
});
|
||||
|
||||
test('excédent : trois membres, deux tables, quatre tours — « excédent d\u{2019}au moins 1 », jamais un plancher nul', () => {
|
||||
const { diagnostic, liste } = lire(CONFIGURATIONS.excedent());
|
||||
assert.equal(diagnostic.collisions[0].plancherExcedent, 1);
|
||||
const [enonce] = avecCle(liste, 'diagnostic.excedent');
|
||||
const phrase = texte(enonce);
|
||||
assert.ok(phrase.includes("excédent d'au moins 1 "), phrase);
|
||||
assert.doesNotMatch(phrase, /≥|\b0\b/);
|
||||
// Une configuration à excédent porte au moins un énoncé d'excédent, et
|
||||
// chacun un plancher positif.
|
||||
for (const cle of ['excedent', 'memeTable']) {
|
||||
const portes = avecCle(lire(CONFIGURATIONS[cle]()).liste, 'diagnostic.excedent');
|
||||
assert.ok(portes.length > 0, cle);
|
||||
for (const { details } of portes) assert.ok(details.plancherExcedent > 0, `${cle} : ${details.plancherExcedent}`);
|
||||
}
|
||||
// Une configuration sans excédent n'en porte aucun énoncé : chaque ligne
|
||||
// de collisions y a un plancher d'excédent null, jamais 0. Les deux
|
||||
// configurations en conflit ont au moins une ligne, la petite aucune.
|
||||
for (const [cle, avecCollisions] of [['petiteConflit', true], ['toutesEnConflit', true], ['petite', false]]) {
|
||||
const lu = lire(CONFIGURATIONS[cle]());
|
||||
assert.equal(avecCle(lu.liste, 'diagnostic.excedent').length, 0, cle);
|
||||
assert.equal(lu.diagnostic.collisions.length > 0, avecCollisions, cle);
|
||||
for (const ligne of lu.diagnostic.collisions) assert.equal(ligne.plancherExcedent, null, `${cle} : ${ligne.groupe}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('deux membres réservés à une même table : le remède redistribue, aucune table ne suffit', () => {
|
||||
const { diagnostic, liste } = lire(CONFIGURATIONS.memeTable());
|
||||
assert.equal(diagnostic.collisions[0].tablesManquantes, null);
|
||||
const portes = [...avecCle(liste, 'diagnostic.collisions'), ...avecCle(liste, 'diagnostic.excedent')];
|
||||
assert.equal(portes.length, 2);
|
||||
for (const enonce of portes) {
|
||||
assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.redistribuer', details: {} });
|
||||
}
|
||||
});
|
||||
|
||||
test('redondance : la configuration de M1 — au moins 6 appartenances recroisées ; avec 3 tours au lieu de 4, aucune', () => {
|
||||
const { diagnostic, liste } = lire(CONFIGURATIONS.redondance());
|
||||
assert.equal(diagnostic.redondanceMinimale, 6);
|
||||
const [enonce] = avecCle(liste, 'diagnostic.redondance');
|
||||
assert.equal(texte(enonce), 'au moins 6 appartenances recroisées par personne');
|
||||
assert.equal(texte(enonce.remede), 'avec 3 tours au lieu de 4, aucune ne le serait');
|
||||
});
|
||||
|
||||
test('redondance sans remède en tours : un seul tour, toursSansRedondance null', () => {
|
||||
const { diagnostic, liste } = lire(CONFIGURATIONS.unTour());
|
||||
assert.ok(diagnostic.redondanceMinimale > 0);
|
||||
assert.equal(diagnostic.remedes.toursSansRedondance, null);
|
||||
const [enonce] = avecCle(liste, 'diagnostic.redondance');
|
||||
assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.appartenances', details: {} });
|
||||
assert.doesNotMatch(texte(enonce.remede), /\d/);
|
||||
for (const { remede } of liste) assert.notEqual(remede?.cle, 'diagnostic.remede.tours');
|
||||
});
|
||||
|
||||
test('animateurs d\u{2019}une même appartenance : la phrase porte 3 et son remède ; absente de la grande', () => {
|
||||
const { diagnostic, liste } = lire(CONFIGURATIONS.animateurs());
|
||||
assert.equal(diagnostic.redondanceImposeeParAnimateurs, 3);
|
||||
const [enonce] = avecCle(liste, 'diagnostic.animateurs');
|
||||
assert.ok(texte(enonce).includes('au moins 3 redondances'), texte(enonce));
|
||||
assert.equal(enonce.remede.cle, 'diagnostic.remede.animateurs');
|
||||
assert.ok(texte(enonce.remede).includes('sans appartenance déclarée'));
|
||||
assert.equal(avecCle(lire(CONFIGURATIONS.grande()).liste, 'diagnostic.animateurs').length, 0);
|
||||
});
|
||||
|
||||
test('ancrage : une table de 3 à 3 ancrés ne tourne plus, par son numéro affiché ; une de 4 à 2 ancrés, 2 sur 4', () => {
|
||||
const { liste } = lire(CONFIGURATIONS.ancrage());
|
||||
const [gelee] = avecCle(liste, 'diagnostic.ancrage.gelee');
|
||||
const [moitie] = avecCle(liste, 'diagnostic.ancrage.moitie');
|
||||
assert.equal(avecCle(liste, 'diagnostic.ancrage.gelee').length, 1);
|
||||
assert.equal(avecCle(liste, 'diagnostic.ancrage.moitie').length, 1);
|
||||
assert.equal(texte(gelee), 'la table 7 ne tourne plus : ses 3 places sont ancrées');
|
||||
assert.deepEqual(moitie.details, { numero: 8, ancres: 2, capacite: 4 });
|
||||
assert.ok(texte(moitie).startsWith('la table 8 a 2 places ancrées sur 4'), texte(moitie));
|
||||
});
|
||||
|
||||
test('écart d\u{2019}itinéraire : la grande, plancher 1, au moins 24 mobiles ; la petite, aucune mention, jamais 0', () => {
|
||||
const grande = lire(CONFIGURATIONS.grande());
|
||||
const [enonce] = avecCle(grande.liste, 'diagnostic.ecartItineraire');
|
||||
assert.deepEqual(enonce.details, grande.diagnostic.ecartItineraire);
|
||||
assert.ok(texte(enonce).includes('plancher 1, au moins 24 mobiles'), texte(enonce));
|
||||
for (const cle of ['petite', 'petiteConflit', 'grandeSansException']) {
|
||||
assert.equal(avecCle(lire(CONFIGURATIONS[cle]()).liste, 'diagnostic.ecartItineraire').length, 0, cle);
|
||||
}
|
||||
});
|
||||
|
||||
test('manque de places : treize personnes, quatre tables de 3 — 1 place manque à chaque tour', () => {
|
||||
const [enonce] = avecCle(lire(CONFIGURATIONS.manque()).liste, 'diagnostic.placesManquantes');
|
||||
assert.equal(texte(enonce), '1 place manque à chaque tour');
|
||||
});
|
||||
|
||||
test('plafond a priori : la grande, 24 pour les 4 ancrés, 28 pour 29 ancrés et 227 mobiles, depuis repartition', () => {
|
||||
const { diagnostic, liste } = lire(CONFIGURATIONS.grande());
|
||||
const plafonds = avecCle(liste, 'diagnostic.plafond');
|
||||
assert.deepEqual(
|
||||
plafonds.map(({ details }) => details),
|
||||
diagnostic.plafondAPriori.repartition,
|
||||
);
|
||||
assert.deepEqual(diagnostic.plafondAPriori.repartition, [
|
||||
{ valeur: 24, ancres: 4, mobiles: 0 },
|
||||
{ valeur: 28, ancres: 29, mobiles: 227 },
|
||||
]);
|
||||
assert.equal(texte(plafonds[0]), 'plafond a priori 24 rencontres pour 4 ancrés');
|
||||
assert.equal(texte(plafonds[1]), 'plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles');
|
||||
});
|
||||
|
||||
test('imposés : la grande, « 99 retours imposés par 33 ancrages »', () => {
|
||||
const [enonce] = avecCle(lire(CONFIGURATIONS.grande()).liste, 'diagnostic.imposes');
|
||||
assert.equal(texte(enonce), '99 retours imposés par 33 ancrages');
|
||||
});
|
||||
|
||||
test('une seule appartenance : la phrase, puis le remède qui désactive la séparation', () => {
|
||||
const { liste } = lire(CONFIGURATIONS.uneSeule());
|
||||
assert.equal(liste[0].cle, 'diagnostic.uneSeuleAppartenance');
|
||||
assert.deepEqual(liste[0].remede, SEPARATION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enonces : aux bords', () => {
|
||||
test('un événement neuf, zéro participant, zéro table : aucun plancher, le plafond a priori « — », jamais 0', () => {
|
||||
const { liste } = lire(chargeDe({ appartenances: [], capacites: [] }));
|
||||
assert.deepEqual(liste, [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }]);
|
||||
assert.ok(texte(liste[0]).includes(TIRET));
|
||||
assert.doesNotMatch(texte(liste[0]), /\d/);
|
||||
});
|
||||
|
||||
test('deux personnes sans table : 2 places manquent à chaque tour', () => {
|
||||
const [enonce] = avecCle(lire(chargeDe({ appartenances: [null, null], capacites: [] })).liste, 'diagnostic.placesManquantes');
|
||||
assert.equal(texte(enonce), '2 places manquent à chaque tour');
|
||||
});
|
||||
|
||||
test('aucune appartenance : ni collisions ni redondance', () => {
|
||||
const { liste } = lire(chargeDe({ appartenances: fois(12, null), capacites: fois(4, 3) }));
|
||||
assert.ok(liste.length > 0);
|
||||
for (const cle of ['diagnostic.collisions', 'diagnostic.excedent', 'diagnostic.redondance', 'diagnostic.uneSeuleAppartenance', 'diagnostic.collisionPartout']) {
|
||||
assert.equal(avecCle(liste, cle).length, 0, cle);
|
||||
}
|
||||
});
|
||||
|
||||
test('numeroDeCharge : le numéro affiché d\u{2019}un identifiant de table ; un identifiant inconnu lève', () => {
|
||||
const numeroDe = numeroDeCharge(CONFIGURATIONS.ancrage());
|
||||
assert.deepEqual([1, 2, 3].map(numeroDe), [7, 8, 9]);
|
||||
assert.throws(() => numeroDe(4), RangeError);
|
||||
});
|
||||
|
||||
test('enonces ne modifie pas le diagnostic reçu', () => {
|
||||
const charge = CONFIGURATIONS.ancrage();
|
||||
const { diagnostic } = diagnostiquerCharge(charge);
|
||||
const avant = structuredClone(diagnostic);
|
||||
enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: 4 });
|
||||
assert.deepEqual(diagnostic, avant);
|
||||
});
|
||||
});
|
||||
204
src/interface/etat_ui.js
Normal file
204
src/interface/etat_ui.js
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'état de navigation de l'interface : la section ouverte, le panneau « À
|
||||
// propos », le tour montré, la personne mise en évidence, la proposition
|
||||
// demandée au plan, le texte d'un import en attente, les listes de noms, le
|
||||
// plein écran, la place sélectionnée, la fiche ouverte, le choix en attente
|
||||
// du plan et le refus posé sur une place. Regarder n'écrit jamais (§ 8.4) : cet
|
||||
// état ne reçoit ni la séance, ni la façade, ni un système de fichiers, et
|
||||
// rien de ce qu'il tient ne quitte la mémoire.
|
||||
//
|
||||
// Il suit le contrat des magasins de Svelte : subscribe(fn) appelle fn(etat)
|
||||
// tout de suite, puis à chaque nouvel état ; un état est figé, et une
|
||||
// transition qui ne change rien n'en publie pas de nouveau. L'écran — liste
|
||||
// ou événement — ne s'y lit pas : il suit la séance.
|
||||
import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} EtatUi
|
||||
* @property {string} section l'une de SECTIONS
|
||||
* @property {boolean} apropos le panneau « À propos » est ouvert
|
||||
* @property {number} tour le tour montré, entier positif
|
||||
* @property {number|null} miseEnEvidence l'identifiant de la personne mise en évidence
|
||||
* @property {number|null} proposition la proposition demandée au plan
|
||||
* @property {null|{texte: string, origine: string}} importEnAttente
|
||||
* @property {boolean} listes les listes de noms sont montrées
|
||||
* @property {boolean} pleinEcran
|
||||
* @property {Object|null} selection la place sélectionnée
|
||||
* @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte
|
||||
* @property {null|{genre: 'personne'|'reservation', participant: number,
|
||||
* depuis: {table: number, siege: number|null}|null,
|
||||
* de?: {table: number, portee: 'tous'|'tour', tour: number|null}}} choix
|
||||
* le choix en attente du plan (§ 5.8, § 7.1) : la personne choisie, ou sa
|
||||
* réservation qui tient la place depuis ; depuis, sa place, null hors du
|
||||
* dessin ; de, pour une réservation, ce qui la désigne dans la commande
|
||||
* deplacerReservation
|
||||
* @property {null|{place: {table: number, siege?: number|null}, refus: Object|null,
|
||||
* avertissements: Object[]}} pose
|
||||
* le refus, ou les avertissements, posés sur une place du plan, que
|
||||
* Plan.svelte montre sur la place (§ 5.9)
|
||||
*/
|
||||
|
||||
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
|
||||
// excepté : celui-ci ne tient pas à l'événement.
|
||||
const DEPART = Object.freeze({
|
||||
section: SECTION_OUVERTURE,
|
||||
tour: 1,
|
||||
miseEnEvidence: null,
|
||||
proposition: null,
|
||||
importEnAttente: null,
|
||||
listes: true,
|
||||
pleinEcran: false,
|
||||
selection: null,
|
||||
fiche: null,
|
||||
choix: null,
|
||||
pose: null,
|
||||
});
|
||||
|
||||
const GENRES_CHOIX = new Set(['personne', 'reservation']);
|
||||
const PORTEES = new Set(['tous', 'tour']);
|
||||
|
||||
const entierPositif = (n) => Number.isInteger(n) && n >= 1;
|
||||
|
||||
// Vrai pour une place {table, siege|null} : table entière positive, siège
|
||||
// entier positif ou null.
|
||||
const estPlace = (place) =>
|
||||
place !== null && typeof place === 'object' && entierPositif(place.table) && (place.siege === null || entierPositif(place.siege));
|
||||
|
||||
// Le choix du contrat, figé ; RangeError hors du contrat.
|
||||
function choixValide(choix) {
|
||||
const forme = choix !== null && typeof choix === 'object';
|
||||
const valide =
|
||||
forme &&
|
||||
GENRES_CHOIX.has(choix.genre) &&
|
||||
entierPositif(choix.participant) &&
|
||||
Object.hasOwn(choix, 'depuis') &&
|
||||
(choix.depuis === null || estPlace(choix.depuis)) &&
|
||||
(choix.genre === 'personne' ||
|
||||
(choix.de !== null &&
|
||||
typeof choix.de === 'object' &&
|
||||
entierPositif(choix.de.table) &&
|
||||
PORTEES.has(choix.de.portee) &&
|
||||
(choix.de.portee === 'tous' ? choix.de.tour === null : entierPositif(choix.de.tour))));
|
||||
if (!valide) throw new RangeError(`choix hors du contrat : ${JSON.stringify(choix)}`);
|
||||
const depuis = choix.depuis === null ? null : Object.freeze({ table: choix.depuis.table, siege: choix.depuis.siege });
|
||||
const fige = { genre: choix.genre, participant: choix.participant, depuis };
|
||||
if (choix.genre === 'reservation') fige.de = Object.freeze({ table: choix.de.table, portee: choix.de.portee, tour: choix.de.tour });
|
||||
return Object.freeze(fige);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un état de navigation neuf, à son départ.
|
||||
*
|
||||
* @returns {{subscribe: Function, etat: () => EtatUi, allerA: Function,
|
||||
* reinitialiser: Function, ouvrirApropos: Function, fermerApropos: Function,
|
||||
* montrerTour: Function, mettreEnEvidence: Function,
|
||||
* montrerProposition: Function, ouvrirImport: Function,
|
||||
* prendreImport: () => null|{texte: string, origine: string},
|
||||
* basculerListes: Function, basculerPleinEcran: Function,
|
||||
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
|
||||
* poserSurPlace: Function}}
|
||||
*/
|
||||
export function creerEtatUi() {
|
||||
let etat = Object.freeze({ ...DEPART, apropos: false });
|
||||
const abonnes = new Set();
|
||||
|
||||
// Publie l'état que donnent les changements, s'il diffère du courant sur
|
||||
// l'une de ses propriétés.
|
||||
function changer(changements) {
|
||||
const suivant = { ...etat, ...changements };
|
||||
if (Object.keys(suivant).every((cle) => Object.is(suivant[cle], etat[cle]))) return;
|
||||
etat = Object.freeze(suivant);
|
||||
for (const fn of [...abonnes]) fn(etat);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(fn) {
|
||||
abonnes.add(fn);
|
||||
fn(etat);
|
||||
return () => {
|
||||
abonnes.delete(fn);
|
||||
};
|
||||
},
|
||||
etat: () => etat,
|
||||
/** Ouvre une section ; RangeError hors de SECTIONS. Une autre section
|
||||
* que la courante retire la pose : elle ne revient pas avec le plan. */
|
||||
allerA(section) {
|
||||
if (!SECTIONS.includes(section)) throw new RangeError(`section inconnue : ${JSON.stringify(section)}`);
|
||||
changer(section === etat.section ? {} : { section, pose: null });
|
||||
},
|
||||
/** Le départ d'un événement qui s'ouvre ; le panneau « À propos » reste tel. */
|
||||
reinitialiser() {
|
||||
changer(DEPART);
|
||||
},
|
||||
ouvrirApropos() {
|
||||
changer({ apropos: true });
|
||||
},
|
||||
fermerApropos() {
|
||||
changer({ apropos: false });
|
||||
},
|
||||
/** Montre le tour r, entier positif ; RangeError sinon. Le choix en
|
||||
* attente s'oublie, et la pose, qui tient à l'état des places d'un tour. */
|
||||
montrerTour(r) {
|
||||
if (!Number.isInteger(r) || r < 1) throw new RangeError(`tour : entier positif attendu, reçu ${String(r)}`);
|
||||
changer({ tour: r, choix: null, pose: null });
|
||||
},
|
||||
mettreEnEvidence(id) {
|
||||
changer({ miseEnEvidence: id });
|
||||
},
|
||||
/** Demande la proposition id au plan, ou aucune (null) ; la pose, lue
|
||||
* sur l'état des places d'avant, se retire. */
|
||||
montrerProposition(id) {
|
||||
changer({ proposition: id, pose: null });
|
||||
},
|
||||
/** Garde un texte à importer et ouvre la section de l'import. */
|
||||
ouvrirImport({ texte, origine }) {
|
||||
changer({ importEnAttente: Object.freeze({ texte, origine }), section: 'import' });
|
||||
},
|
||||
/** Rend le texte en attente, une fois, puis null. */
|
||||
prendreImport() {
|
||||
const enAttente = etat.importEnAttente;
|
||||
changer({ importEnAttente: null });
|
||||
return enAttente;
|
||||
},
|
||||
basculerListes() {
|
||||
changer({ listes: !etat.listes });
|
||||
},
|
||||
basculerPleinEcran() {
|
||||
changer({ pleinEcran: !etat.pleinEcran });
|
||||
},
|
||||
/** Sélectionne une place ; null retire la sélection et oublie le choix en attente. */
|
||||
selectionner(place) {
|
||||
changer(place === null ? { selection: null, choix: null } : { selection: place });
|
||||
},
|
||||
/** Pose le choix en attente (EtatUi.choix), ou l'oublie (null) ; RangeError hors du contrat. */
|
||||
choisir(choix) {
|
||||
changer({ choix: choix === null ? null : choixValide(choix) });
|
||||
},
|
||||
/** Pose sur une place un refus, ou des avertissements, que le plan montre ; null les retire.
|
||||
* RangeError sans place. */
|
||||
poserSurPlace(pose) {
|
||||
if (pose === null) {
|
||||
changer({ pose: null });
|
||||
return;
|
||||
}
|
||||
const { place, refus = null, avertissements = [] } = pose;
|
||||
if (place === null || typeof place !== 'object' || !entierPositif(place.table)) {
|
||||
throw new RangeError(`pose sans place : ${JSON.stringify(pose)}`);
|
||||
}
|
||||
changer({ pose: Object.freeze({ place, refus, avertissements }) });
|
||||
},
|
||||
/** Ouvre la fiche de la personne id, entier positif, dans la section des
|
||||
* participants ; null referme la fiche, la section reste. RangeError
|
||||
* sinon. */
|
||||
ouvrirFiche(id) {
|
||||
if (id === null) {
|
||||
changer({ fiche: null });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(id) || id < 1) throw new RangeError(`fiche : entier positif ou null attendu, reçu ${String(id)}`);
|
||||
changer({ fiche: id, section: 'participants' });
|
||||
},
|
||||
};
|
||||
}
|
||||
270
src/interface/etat_ui.test.js
Normal file
270
src/interface/etat_ui.test.js
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'état de navigation de l'interface : chaque transition, le contrat des
|
||||
// magasins de Svelte, et l'absence de toute écriture — l'état ne connaît pas
|
||||
// la séance, et regarder n'écrit jamais (§ 8.2, § 8.4).
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { creerEtatUi } from './etat_ui.js';
|
||||
import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
|
||||
|
||||
const DEPART = {
|
||||
section: SECTION_OUVERTURE,
|
||||
apropos: false,
|
||||
tour: 1,
|
||||
miseEnEvidence: null,
|
||||
proposition: null,
|
||||
importEnAttente: null,
|
||||
listes: true,
|
||||
pleinEcran: false,
|
||||
selection: null,
|
||||
fiche: null,
|
||||
choix: null,
|
||||
pose: null,
|
||||
};
|
||||
|
||||
// Un état et le relevé de ce que reçoit son abonné.
|
||||
function suivi() {
|
||||
const ui = creerEtatUi();
|
||||
const recus = [];
|
||||
const desabonner = ui.subscribe((etat) => recus.push(etat));
|
||||
return { ui, recus, desabonner };
|
||||
}
|
||||
|
||||
describe('état de navigation : départ et magasin', () => {
|
||||
test('le départ : la section d’ouverture, le tour 1, rien de choisi, les listes montrées', () => {
|
||||
assert.deepEqual(creerEtatUi().etat(), DEPART);
|
||||
});
|
||||
|
||||
test('subscribe rend l’état tout de suite, puis chaque nouvel état, figé ; désabonné, plus rien', () => {
|
||||
const { ui, recus, desabonner } = suivi();
|
||||
assert.deepEqual(recus, [DEPART]);
|
||||
ui.allerA('tables');
|
||||
assert.equal(recus.length, 2);
|
||||
assert.equal(recus[1], ui.etat());
|
||||
assert.ok(Object.isFrozen(recus[1]));
|
||||
assert.throws(() => {
|
||||
recus[1].section = 'plan';
|
||||
}, TypeError);
|
||||
desabonner();
|
||||
ui.allerA('plan');
|
||||
assert.equal(recus.length, 2);
|
||||
});
|
||||
|
||||
test('l’état ne reçoit rien à sa création : il ne connaît ni la séance, ni la façade, ni un système de fichiers', () => {
|
||||
assert.equal(creerEtatUi.length, 0);
|
||||
const source = readFileSync(new URL('./etat_ui.js', import.meta.url), 'utf8');
|
||||
// Le dernier mot de chaque ligne d'import : le module qu'elle charge.
|
||||
const imports = source
|
||||
.split('\n')
|
||||
.filter((ligne) => ligne.startsWith('import '))
|
||||
.map((ligne) => ligne.slice(ligne.lastIndexOf(' ') + 1));
|
||||
assert.deepEqual(imports, ["'./evenement/sections.js';"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('état de navigation : chaque transition', () => {
|
||||
test('allerA mène à chaque section ; hors de SECTIONS, RangeError sans rien changer', () => {
|
||||
const ui = creerEtatUi();
|
||||
assert.ok(SECTIONS.length > 0);
|
||||
for (const section of SECTIONS) {
|
||||
ui.allerA(section);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, section });
|
||||
}
|
||||
const avant = ui.etat();
|
||||
for (const faute of ['accueil', 'apropos', '', undefined, 'Plan', 'constructor']) {
|
||||
assert.throws(() => ui.allerA(faute), RangeError, String(faute));
|
||||
}
|
||||
assert.equal(ui.etat(), avant);
|
||||
});
|
||||
|
||||
test('reinitialiser rend l’état de départ, le panneau « À propos » excepté', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.allerA('generation');
|
||||
ui.montrerTour(3);
|
||||
ui.mettreEnEvidence(7);
|
||||
ui.montrerProposition(2);
|
||||
ui.ouvrirImport({ texte: 'nom\nOmbrelle', origine: 'liste.csv' });
|
||||
ui.basculerListes();
|
||||
ui.basculerPleinEcran();
|
||||
ui.selectionner({ table: 1, siege: 2 });
|
||||
ui.ouvrirFiche(4);
|
||||
ui.choisir({ genre: 'personne', participant: 2, depuis: null });
|
||||
ui.poserSurPlace({ place: { table: 1, siege: 2 }, refus: { code: 'SANS_EFFET', details: {} }, avertissements: [] });
|
||||
ui.ouvrirApropos();
|
||||
ui.reinitialiser();
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
|
||||
assert.equal(ui.etat().section, SECTION_OUVERTURE);
|
||||
});
|
||||
|
||||
test('ouvrirApropos et fermerApropos', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.ouvrirApropos();
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
|
||||
ui.fermerApropos();
|
||||
assert.deepEqual(ui.etat(), DEPART);
|
||||
});
|
||||
|
||||
test('montrerTour prend un entier positif ; sinon RangeError', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.montrerTour(4);
|
||||
assert.equal(ui.etat().tour, 4);
|
||||
for (const faute of [0, -1, 1.5, Number.NaN, '2', null]) {
|
||||
assert.throws(() => ui.montrerTour(faute), RangeError, String(faute));
|
||||
}
|
||||
assert.equal(ui.etat().tour, 4);
|
||||
});
|
||||
|
||||
test('mettreEnEvidence, montrerProposition et selectionner posent puis retirent leur choix', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.mettreEnEvidence(12);
|
||||
ui.montrerProposition(3);
|
||||
ui.selectionner({ table: 2, siege: 1 });
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, miseEnEvidence: 12, proposition: 3, selection: { table: 2, siege: 1 } });
|
||||
ui.mettreEnEvidence(null);
|
||||
ui.montrerProposition(null);
|
||||
ui.selectionner(null);
|
||||
assert.deepEqual(ui.etat(), DEPART);
|
||||
});
|
||||
|
||||
test('ouvrirImport garde le texte en attente et mène à la section import ; prendreImport le rend une fois', () => {
|
||||
const ui = creerEtatUi();
|
||||
const enAttente = { texte: 'nom;prénom\nPervenche;Théo', origine: 'invites.csv' };
|
||||
ui.ouvrirImport(enAttente);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, section: 'import', importEnAttente: enAttente });
|
||||
assert.deepEqual(ui.prendreImport(), enAttente);
|
||||
assert.equal(ui.etat().importEnAttente, null);
|
||||
assert.equal(ui.prendreImport(), null);
|
||||
});
|
||||
|
||||
test('basculerListes et basculerPleinEcran alternent', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.basculerListes();
|
||||
ui.basculerPleinEcran();
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, listes: false, pleinEcran: true });
|
||||
ui.basculerListes();
|
||||
ui.basculerPleinEcran();
|
||||
assert.deepEqual(ui.etat(), DEPART);
|
||||
});
|
||||
|
||||
test('ouvrirFiche(id) mène à la section participants et ouvre la fiche ; ouvrirFiche(null) la referme, la section reste', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.allerA('plan');
|
||||
ui.ouvrirFiche(7);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, section: 'participants', fiche: 7 });
|
||||
ui.allerA('tables');
|
||||
ui.ouvrirFiche(null);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, section: 'tables', fiche: null });
|
||||
});
|
||||
|
||||
test('ouvrirFiche prend un identifiant entier positif ou null ; sinon RangeError sans rien changer', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.ouvrirFiche(3);
|
||||
const avant = ui.etat();
|
||||
for (const faute of [0, -2, 1.5, Number.NaN, '3', undefined]) {
|
||||
assert.throws(() => ui.ouvrirFiche(faute), RangeError, String(faute));
|
||||
}
|
||||
assert.equal(ui.etat(), avant);
|
||||
});
|
||||
|
||||
test('une transition qui ne change rien ne publie pas d’état neuf', () => {
|
||||
const { ui, recus } = suivi();
|
||||
ui.allerA(SECTION_OUVERTURE);
|
||||
ui.fermerApropos();
|
||||
ui.mettreEnEvidence(null);
|
||||
ui.ouvrirFiche(null);
|
||||
assert.equal(recus.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('état de navigation : le choix en attente (§ 5.8, § 7.1)', () => {
|
||||
const PERSONNE = { genre: 'personne', participant: 2, depuis: { table: 4, siege: 1 } };
|
||||
const DEPUIS_LA_RESERVE = { genre: 'personne', participant: 3, depuis: null };
|
||||
const RESERVATION = {
|
||||
genre: 'reservation',
|
||||
participant: 5,
|
||||
depuis: { table: 3, siege: 2 },
|
||||
de: { table: 3, portee: 'tous', tour: null },
|
||||
};
|
||||
|
||||
test('choisir pose un choix de chaque genre, figé, puis null l’oublie', () => {
|
||||
const ui = creerEtatUi();
|
||||
for (const choix of [PERSONNE, DEPUIS_LA_RESERVE, RESERVATION]) {
|
||||
ui.choisir(choix);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, choix });
|
||||
assert.ok(Object.isFrozen(ui.etat().choix));
|
||||
ui.choisir(null);
|
||||
assert.deepEqual(ui.etat(), DEPART);
|
||||
}
|
||||
});
|
||||
|
||||
test('un choix hors du contrat lève RangeError sans rien changer', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.choisir(PERSONNE);
|
||||
const avant = ui.etat();
|
||||
const fautes = [
|
||||
{ genre: 'table', participant: 2, depuis: null },
|
||||
{ genre: 'personne', participant: 0, depuis: null },
|
||||
{ genre: 'personne', participant: '2', depuis: null },
|
||||
{ genre: 'personne', participant: 2 },
|
||||
{ genre: 'personne', participant: 2, depuis: { siege: 1 } },
|
||||
{ genre: 'personne', participant: 2, depuis: { table: 1, siege: 0 } },
|
||||
{ genre: 'reservation', participant: 5, depuis: { table: 3, siege: 2 } },
|
||||
{ genre: 'reservation', participant: 5, depuis: null, de: { table: 3, portee: 'parfois', tour: null } },
|
||||
undefined,
|
||||
];
|
||||
for (const faute of fautes) assert.throws(() => ui.choisir(faute), RangeError, JSON.stringify(faute));
|
||||
assert.equal(ui.etat(), avant);
|
||||
});
|
||||
|
||||
test('montrerTour et selectionner(null) oublient le choix ; selectionner une place le garde', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.choisir(PERSONNE);
|
||||
ui.selectionner({ table: 2, siege: 3 });
|
||||
assert.deepEqual(ui.etat().choix, PERSONNE);
|
||||
ui.selectionner(null);
|
||||
assert.equal(ui.etat().choix, null);
|
||||
ui.choisir(RESERVATION);
|
||||
ui.montrerTour(2);
|
||||
assert.equal(ui.etat().choix, null);
|
||||
ui.choisir(PERSONNE);
|
||||
ui.montrerTour(2);
|
||||
assert.equal(ui.etat().choix, null, 'le même tour remontré oublie aussi');
|
||||
});
|
||||
|
||||
test('poserSurPlace pose un refus ou des avertissements sur une place, puis null les retire ; aucune transition ne reçoit la séance', () => {
|
||||
const ui = creerEtatUi();
|
||||
const pose = {
|
||||
place: { table: 3, siege: null },
|
||||
refus: { code: 'PERSONNE_EXCLUE', details: { participant: 1 } },
|
||||
avertissements: [{ code: 'TABLE_GELEE', details: { table: 3 } }],
|
||||
};
|
||||
ui.poserSurPlace(pose);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, pose });
|
||||
assert.ok(Object.isFrozen(ui.etat().pose));
|
||||
ui.poserSurPlace(null);
|
||||
assert.deepEqual(ui.etat(), DEPART);
|
||||
assert.throws(() => ui.poserSurPlace({ place: null, refus: null, avertissements: [] }), RangeError);
|
||||
assert.equal(ui.choisir.length, 1);
|
||||
assert.equal(ui.poserSurPlace.length, 1);
|
||||
});
|
||||
|
||||
test('montrerTour, montrerProposition et un changement de section retirent la pose ; la section remontrée la garde', () => {
|
||||
const ui = creerEtatUi();
|
||||
const pose = { place: { table: 7, siege: 1 }, refus: { code: 'PLACE_RESERVEE', details: { participant: 4 } }, avertissements: [] };
|
||||
ui.poserSurPlace(pose);
|
||||
ui.montrerTour(2);
|
||||
assert.equal(ui.etat().pose, null, 'montrerTour');
|
||||
ui.poserSurPlace(pose);
|
||||
ui.montrerProposition(1);
|
||||
assert.equal(ui.etat().pose, null, 'montrerProposition');
|
||||
ui.allerA('plan');
|
||||
ui.poserSurPlace(pose);
|
||||
ui.allerA('plan');
|
||||
assert.deepEqual(ui.etat().pose, pose, 'la même section');
|
||||
ui.allerA('tables');
|
||||
assert.equal(ui.etat().pose, null, 'une autre section');
|
||||
});
|
||||
});
|
||||
108
src/interface/evenement/Avertissements.svelte
Normal file
108
src/interface/evenement/Avertissements.svelte
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Les avertissements de l'événement ouvert (§ 8.6, § 8.8, § 8.9, § 9), dans
|
||||
// l'ordre de la séance (avertissementsOrdonnes, modele.js) : chacun par le
|
||||
// texte de son code, puis ce qu'il nomme — les propositions écartées, leur
|
||||
// numéro et leurs fautes ; les placements en dérive et leurs raisons —,
|
||||
// puis son remède en Commande, par actionRemede (primitives/commande.js).
|
||||
// Un avertissement n'empêche rien.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { actionRemede } from '../primitives/commande.js';
|
||||
import { avertissementsOrdonnes } from './modele.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
|
||||
const liste = $derived(avertissementsOrdonnes($seance));
|
||||
</script>
|
||||
|
||||
{#if liste.length > 0}
|
||||
<section class="avertissements" aria-label={t('evenement.avertissements')}>
|
||||
{#each liste as avertissement, rang (rang)}
|
||||
{@const action = actionRemede(avertissement.remede, { seance, ui })}
|
||||
<div class="avertissement" role="status" data-avertissement={avertissement.code}>
|
||||
<p>{t(avertissement.code, avertissement.details)}</p>
|
||||
{#if avertissement.fautives !== undefined && avertissement.fautives.length > 0}
|
||||
<ul>
|
||||
{#each avertissement.fautives as fautive, n (n)}
|
||||
<li data-fautive={fautive.id ?? ''}>
|
||||
{#if fautive.id !== null}
|
||||
<span>{t('evenement.fautive.numero')}</span>
|
||||
<span class="donnee">{fautive.id}</span>
|
||||
{:else}
|
||||
<span>{t('evenement.fautive.rang')}</span>
|
||||
<span class="donnee">{fautive.rang}</span>
|
||||
{/if}
|
||||
{#each fautive.fautes as faute, f (f)}
|
||||
<span>{t(faute.code, faute)}</span>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if avertissement.derives !== undefined && avertissement.derives.length > 0}
|
||||
<ul>
|
||||
{#each avertissement.derives as derive (derive.id)}
|
||||
<li data-derive={derive.id}>
|
||||
{#if derive.id === 'retenu'}
|
||||
<span>{t('evenement.derive.retenu')}</span>
|
||||
{:else}
|
||||
<span>{t('evenement.derive.proposition')}</span>
|
||||
<span class="donnee">{derive.id}</span>
|
||||
{/if}
|
||||
{#each derive.raisons as raison, r (r)}
|
||||
<span>{t(raison.code, raison)}</span>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if action !== null}
|
||||
<Commande cle={action.cle} details={action.details} nom={action.nom} executer={action.executer} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.avertissements {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
margin-bottom: var(--espace-3);
|
||||
}
|
||||
|
||||
.avertissement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
p,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: var(--espace-4);
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-2);
|
||||
}
|
||||
|
||||
.donnee {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
210
src/interface/evenement/CadreMode.svelte
Normal file
210
src/interface/evenement/CadreMode.svelte
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le cadre de mode (§ 8.4, § 8.5, § 14.5) : un trait autour de la surface
|
||||
// de travail, dont la forme dit le mode — tirets en lecture, plein en
|
||||
// écriture, double sur un plan bloqué —, et une barre qui porte, de gauche
|
||||
// à droite, la place fixe, le libellé de mode, puis « Défaire » et
|
||||
// « Refaire » (§ 8.3). Sous la barre vient la tête, tete, quand elle est
|
||||
// donnée — l'en-tête de l'événement et la navigation des sections —, puis
|
||||
// la surface, children, qui défile seule. La barre ouvre le cadre : rien
|
||||
// ne la précède dans l'écran de l'événement, si bien qu'un refus posé dans
|
||||
// la tête, ou un nom qui s'étend sur plusieurs lignes, allonge la tête
|
||||
// sans déplacer la place fixe (§ 8.4).
|
||||
//
|
||||
// La place fixe porte la commande que rend commandeFixe (modele.js) :
|
||||
// « Modifier », « Débloquer » — confirmé en place, par la Commande d'I2 —,
|
||||
// « Revenir en lecture », ou, pour un format plus récent, l'explication du
|
||||
// refus courant à la place de toute commande. Le verrou d'une autre séance
|
||||
// qui a refusé le passage en écriture s'y nomme à côté du refus — sa
|
||||
// séance et l'instant de sa prise, chacun dans son nœud — et, quand sa vie
|
||||
// ne se prouve pas, « Reprendre le verrou » s'y offre, confirmé en place à
|
||||
// côté de ce qui le nomme (§ 8.8). « Refaire » n'est là que lorsqu'un
|
||||
// retour se refait ; « Défaire » est toujours là, et la séance refuse à
|
||||
// côté de lui ce qui ne se défait pas.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { CONFIRMATION_DEBLOCAGE } from '../primitives/commande.js';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { cadreDeMode, commandeFixe, repriseOfferte } from './modele.js';
|
||||
|
||||
let { children, tete = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
||||
// Sans événement — l'instant où l'écran se retire —, le cadre garde la
|
||||
// forme de la lecture, et n'a pas de libellé.
|
||||
const cadre = $derived(cadreDeMode($seance));
|
||||
const forme = $derived(cadre?.forme ?? 'tirets');
|
||||
const commande = $derived(commandeFixe($seance));
|
||||
const verrou = $derived($seance.mode === 'lecture' ? $seance.verrouAutre : null);
|
||||
const reprise = $derived(repriseOfferte($seance));
|
||||
</script>
|
||||
|
||||
<div class="cadre" data-mode={forme} class:tirets={forme === 'tirets'} class:double={forme === 'double'}>
|
||||
<div class="barre" role="group" aria-label={t('evenement.cadre.commandes')}>
|
||||
<div class="ligne">
|
||||
<div class="fixe" data-place-fixe>
|
||||
{#if commande === 'format'}
|
||||
<Refus refus={$seance.refus} />
|
||||
{:else if commande === 'modifier'}
|
||||
<Commande cle="communs.commande.modifier" nom="modifier" executer={() => seance.passerEnEcriture()} />
|
||||
{:else if commande === 'debloquer'}
|
||||
<Commande
|
||||
cle="communs.commande.debloquer"
|
||||
nom="debloquer"
|
||||
confirmer={CONFIRMATION_DEBLOCAGE}
|
||||
executer={() => seance.debloquer()}
|
||||
/>
|
||||
{:else if commande === 'lecture'}
|
||||
<Commande cle="communs.commande.lecture" nom="lecture" executer={() => seance.passerEnLecture()} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if cadre !== null}
|
||||
<span class="libelle" data-mode-libelle="cadre">{t(cadre.cle)}</span>
|
||||
{/if}
|
||||
{#if commande !== 'format'}
|
||||
<div class="historique" data-historique>
|
||||
<Commande cle="evenement.commande.defaire" nom="defaire" executer={() => seance.defaire()} />
|
||||
{#if $seance.retours.refaire}
|
||||
<Commande cle="evenement.commande.refaire" nom="refaire" executer={() => seance.refaire()} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if verrou !== null}
|
||||
<div class="verrou" data-verrou>
|
||||
<p>{t('evenement.verrou.titre')}</p>
|
||||
<p>
|
||||
<span>{t('evenement.verrou.seance')}</span>
|
||||
<span class="donnee" data-verrou-seance>{verrou.seance ?? t('evenement.verrou.inconnu')}</span>
|
||||
<span>{t('evenement.verrou.depuis')}</span>
|
||||
<span class="donnee" data-verrou-depuis>{verrou.depuis ?? t('evenement.verrou.inconnu')}</span>
|
||||
</p>
|
||||
{#if reprise !== null}
|
||||
<p>{t('evenement.verrou.avis')}</p>
|
||||
<Commande
|
||||
cle="communs.remede.reprendreVerrou"
|
||||
nom="reprendreVerrou"
|
||||
confirmer="evenement.verrou.confirmerReprise"
|
||||
executer={() => seance.reprendreVerrou()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if tete !== null}
|
||||
<div class="tete">
|
||||
{@render tete()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="surface">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Le trait du cadre : sa forme dit le mode, sa couleur le redit. */
|
||||
/* Une même épaisseur dans les trois formes : la place fixe ne bouge pas
|
||||
d'un mode à l'autre. */
|
||||
.cadre {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 6px solid var(--couleur-cadre-ecriture);
|
||||
border-radius: var(--rayon-2);
|
||||
}
|
||||
|
||||
.cadre.tirets {
|
||||
border-style: dashed;
|
||||
border-color: var(--couleur-cadre-lecture);
|
||||
}
|
||||
|
||||
.cadre.double {
|
||||
border-style: double;
|
||||
border-color: var(--couleur-cadre-bloque);
|
||||
}
|
||||
|
||||
/* La barre ne prend jamais toute la hauteur du cadre : au-delà, ses
|
||||
messages défilent, la place fixe en tête. */
|
||||
.barre {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
max-height: 50%;
|
||||
overflow-y: auto;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-bottom: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
.ligne {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
}
|
||||
|
||||
.fixe {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.libelle {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.historique {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.verrou {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.verrou p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.donnee {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* La tête ne prend jamais plus du tiers du cadre : au-delà, elle défile,
|
||||
et la surface garde sa place. */
|
||||
.tete {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
max-height: 35%;
|
||||
overflow-y: auto;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-bottom: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
.surface {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--espace-3);
|
||||
}
|
||||
</style>
|
||||
67
src/interface/evenement/EnteteEvenement.svelte
Normal file
67
src/interface/evenement/EnteteEvenement.svelte
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// L'en-tête de l'événement ouvert (§ 9) : son nom, puis ses quatre états,
|
||||
// dans l'ordre du § 9. Un état se pose en le cliquant, par la commande
|
||||
// changerEtat de la séance — « Bloqué » est la commande « Bloquer », après
|
||||
// laquelle la séance revient en lecture et rend le verrou. Ce que la séance
|
||||
// refuse — la lecture, un plan bloqué, « Retenu » sans placement retenu,
|
||||
// « Proposé » sans proposition — s'affiche à côté de l'état cliqué, avec
|
||||
// son remède. L'état courant se distingue par sa forme : gras, et un trait
|
||||
// sous lui. Le nom se coupe où il le faut, même sans espace : un nom long
|
||||
// de glyphes larges passe à la ligne au lieu d'élargir la page.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { ETATS_PLAN, cleEtat } from './modele.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
||||
const courant = $derived($seance.evenement?.etat ?? null);
|
||||
</script>
|
||||
|
||||
<header class="entete">
|
||||
<h1>{$seance.evenement?.nom}</h1>
|
||||
<div class="etats" role="group" aria-label={t('evenement.etat.titre')}>
|
||||
{#each ETATS_PLAN as etat (etat)}
|
||||
<span class="etat" class:courant={courant === etat} data-etat={etat}>
|
||||
<Commande cle={cleEtat(etat)} nom="etat" executer={() => seance.executer('changerEtat', { etat })} />
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.entete {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2) var(--espace-4);
|
||||
}
|
||||
|
||||
h1 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.etats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.etat {
|
||||
padding-bottom: var(--espace-1);
|
||||
border-bottom: 3px solid var(--couleur-page-fond);
|
||||
}
|
||||
|
||||
.etat.courant {
|
||||
border-bottom-color: var(--couleur-page-texte);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
869
src/interface/evenement/Evenement.navigateur.test.js
Normal file
869
src/interface/evenement/Evenement.navigateur.test.js
Normal file
|
|
@ -0,0 +1,869 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'événement ouvert dans un vrai moteur de rendu (§ 2.1, § 8.3 à § 8.9,
|
||||
// § 9, § 14.5) : aucune section n'en exige une autre ; la place fixe de
|
||||
// « Modifier », de « Débloquer » et de « Revenir en lecture », plein écran
|
||||
// compris ; le cadre de mode et son libellé ; l'inactivité ; l'état bloqué
|
||||
// et son déblocage confirmé en place ; les états du plan ; défaire et
|
||||
// refaire ; les annonces d'ouverture, du journal et de la dérive ; le verrou
|
||||
// d'une autre séance et sa reprise ; le support rompu ; le format plus
|
||||
// récent. Chaque épreuve monte App par monterApp, sur le système de fichiers
|
||||
// simulé, au cadre de 1 366 × 700, et lit le rendu : la boîte, l'élément
|
||||
// qu'atteint un point, le style calculé, le texte — jamais une classe.
|
||||
//
|
||||
// Les éléments s'atteignent par leurs attributs data-… : data-place-fixe, la
|
||||
// place fixe du cadre ; data-etat, un état du plan de l'en-tête ;
|
||||
// data-verrou, le verrou d'une autre séance ; data-support-rompu, le message
|
||||
// du support rompu. Le texte attendu vient de la même traduction que celle
|
||||
// de l'application ; les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
|
||||
import { INACTIVITE_MS } from '../../application/seance.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { apercevoir } from '../../csv/apercu.js';
|
||||
import { FLUX, creerPcg32 } from '../../demo/prng.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { FORMAT, analyser, configurationDepuisCharge, etatDeduit } from '../../stockage/document.js';
|
||||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { visible } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { CONFIRMATION_DEBLOCAGE, DELAI_CONFIRMATION_MS } from '../primitives/commande.js';
|
||||
import { SECTIONS } from './sections.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Merlette', 'Fauvelle'];
|
||||
const EVENEMENT = { nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 };
|
||||
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
|
||||
|
||||
// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs
|
||||
// n'a rien reçu pendant l'épreuve.
|
||||
let montes = [];
|
||||
async function monter(options) {
|
||||
const m = await monterApp(options);
|
||||
montes.push(m);
|
||||
return m;
|
||||
}
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
afterEach(() => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// --- Les lectures du rendu ----------------------------------------------------
|
||||
|
||||
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
|
||||
const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
|
||||
const cadre = (m) => dans(m, '[data-mode]');
|
||||
const fixe = (m, nom) => dans(m, `[data-place-fixe] [data-commande="${nom}"]`);
|
||||
const historique = (m, nom) => dans(m, `[data-mode] [data-historique] [data-commande="${nom}"]`);
|
||||
const boutonEtat = (m, etat) => dans(m, `[data-etat="${etat}"] [data-commande="etat"]`);
|
||||
const libelleCadre = (m) => dans(m, '[data-mode-libelle="cadre"]');
|
||||
const libelleBandeau = (m) => dans(m, '[data-mode-libelle="bandeau"]');
|
||||
const avertissement = (m, code) => dans(m, `[data-avertissement="${code}"]`);
|
||||
const ecran = (m) => dans(m, '[data-ecran]');
|
||||
|
||||
// Le coin haut gauche de la boîte d'un élément.
|
||||
const coin = (element) => {
|
||||
const { left, top } = element.getBoundingClientRect();
|
||||
return [left, top];
|
||||
};
|
||||
|
||||
// L'élément qu'atteint le centre de la boîte d'un élément.
|
||||
const auCentre = (element) => {
|
||||
const { left, top, width, height } = element.getBoundingClientRect();
|
||||
return document.elementFromPoint(left + width / 2, top + height / 2);
|
||||
};
|
||||
|
||||
// Le refus posé à côté d'un bouton de Commande : dans son conteneur.
|
||||
const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`);
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Les événements d'épreuve -----------------------------------------------------
|
||||
|
||||
const baseDe = (m) => m.seance.etat().evenement.base;
|
||||
const fichier = (m, suffixe, racine = 'documents') => m.fs.contenu(racine, `${baseDe(m)}${suffixe}`);
|
||||
const journalDe = (m, racine = 'documents') => lireJournal(fichier(m, SUFFIXES.journal, racine));
|
||||
|
||||
// Un montage où l'événement neuf « Club des Merles », zéro participant et
|
||||
// zéro table, est ouvert en lecture.
|
||||
async function neuf(options = {}) {
|
||||
const m = await monter(options);
|
||||
await m.seance.creer(EVENEMENT);
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
// L'événement ouvert en écriture, garni par la séance des six participants
|
||||
// et de trois tables de trois places.
|
||||
async function garnir(m) {
|
||||
await m.seance.passerEnEcriture();
|
||||
for (const nom of NOMS) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||||
for (let n = 0; n < 3; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Enregistre deux propositions du moteur, puis, quand retenir le veut, retient
|
||||
// la première.
|
||||
async function generer(m, { retenir = true } = {}) {
|
||||
const charge = m.seance.etat().charge;
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, { graine: 7, arret: 40, nombre: 2 });
|
||||
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
|
||||
if (retenir) await m.seance.executer('retenirProposition', { id: m.seance.etat().charge.propositions[0].id });
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Réécrit l'état d'un événement fermé : change(charge) le modifie en place,
|
||||
// et l'état se sérialise à sa révision.
|
||||
function reecrireEtat(m, base, change) {
|
||||
const chemin = `${base}${SUFFIXES.etat}`;
|
||||
const { entete, charge } = analyser(m.fs.contenu('documents', chemin));
|
||||
change(charge);
|
||||
m.fs.deposer('documents', chemin, serialiser(charge, { revision: entete.revision, produitVersion: entete.produitVersion }));
|
||||
}
|
||||
|
||||
// --- Les épreuves -----------------------------------------------------------------
|
||||
|
||||
describe('aucun assistant (§ 2.1)', () => {
|
||||
test('un événement neuf : chaque section s’ouvre depuis la navigation, dans un ordre tiré puis renversé, et rend son titre ; aucune n’est grisée, aucun « Suivant »', async () => {
|
||||
const m = await neuf();
|
||||
const { charge } = m.seance.etat();
|
||||
expect([charge.participants.length, charge.tables.length]).toEqual([0, 0]);
|
||||
const ordre = creerPcg32(20261006, FLUX.DEMO).melanger([...SECTIONS]);
|
||||
expect(ordre.length).toBe(7);
|
||||
for (const nom of [...ordre, ...[...ordre].reverse()]) {
|
||||
const bouton = dans(m, `[data-commande="section"][data-cible="${nom}"]`);
|
||||
expect(visible(bouton), nom).toBe(true);
|
||||
bouton.click();
|
||||
flushSync();
|
||||
expect(m.ui.etat().section).toBe(nom);
|
||||
const section = dans(m, `[data-section="${nom}"]`);
|
||||
expect(section, nom).not.toBeNull();
|
||||
expect(section.textContent).toContain(t(`coquille.section.${nom}`));
|
||||
}
|
||||
const boutons = tous(m, '[data-ecran="evenement"] button');
|
||||
expect(boutons.length).toBeGreaterThan(0);
|
||||
for (const bouton of boutons) {
|
||||
expect(bouton.disabled).toBe(false);
|
||||
expect(bouton.getAttribute('aria-disabled')).toBeNull();
|
||||
}
|
||||
expect(ecran(m).textContent).not.toMatch(/suivant/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la place fixe (§ 8.4, § 8.5)', () => {
|
||||
test('« Modifier » aux mêmes coordonnées sur les sept sections ; plein écran, il reste visible et atteint ; « Revenir en lecture » puis « Débloquer » au même endroit', async () => {
|
||||
const m = await neuf();
|
||||
const coins = [];
|
||||
for (const nom of SECTIONS) {
|
||||
m.ui.allerA(nom);
|
||||
flushSync();
|
||||
expect(visible(fixe(m, 'modifier')), nom).toBe(true);
|
||||
coins.push(coin(fixe(m, 'modifier')));
|
||||
}
|
||||
expect(coins.length).toBe(7);
|
||||
for (const lu of coins) expect(lu).toEqual(coins[0]);
|
||||
// Un refus posé à côté d'un état, au-dessus de la surface, ne déplace
|
||||
// pas la place fixe, et ne la couvre pas.
|
||||
boutonEtat(m, 'bloque').click();
|
||||
await attendre(() => expect(dans(m, '[data-etat="bloque"] [data-refus="MODE_LECTURE"]')).not.toBeNull());
|
||||
expect(coin(fixe(m, 'modifier'))).toEqual(coins[0]);
|
||||
expect(visible(fixe(m, 'modifier'))).toBe(true);
|
||||
m.ui.allerA('plan');
|
||||
m.ui.basculerPleinEcran();
|
||||
flushSync();
|
||||
expect(visible(fixe(m, 'modifier'))).toBe(true);
|
||||
expect(fixe(m, 'modifier').contains(auCentre(fixe(m, 'modifier')))).toBe(true);
|
||||
expect(coin(fixe(m, 'modifier'))).toEqual(coins[0]);
|
||||
expect(dans(m, '[data-commande="section"]')).toBeNull();
|
||||
m.ui.basculerPleinEcran();
|
||||
flushSync();
|
||||
await m.seance.passerEnEcriture();
|
||||
flushSync();
|
||||
expect(fixe(m, 'modifier')).toBeNull();
|
||||
expect(fixe(m, 'lecture').textContent).toBe(t('communs.commande.lecture'));
|
||||
expect(coin(fixe(m, 'lecture'))).toEqual(coins[0]);
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
flushSync();
|
||||
expect(fixe(m, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
|
||||
expect(coin(fixe(m, 'debloquer'))).toEqual(coins[0]);
|
||||
expect(visible(fixe(m, 'debloquer'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('un nom long ou de glyphes larges (point d’attention 3)', () => {
|
||||
test('deux cents caractères sans espace : la page ne s’élargit pas, et « Modifier » garde sa place', async () => {
|
||||
const m = await neuf();
|
||||
const place = coin(fixe(m, 'modifier'));
|
||||
await m.seance.fermer();
|
||||
for (const motif of ['W', 'WW W', '\u{C6}\u{C6}\u{C6}', '\u{D4}jgq']) {
|
||||
const nom = motif.repeat(Math.ceil(200 / motif.length)).slice(0, 200);
|
||||
expect(nom.length).toBe(200);
|
||||
await m.seance.creer({ ...EVENEMENT, nom });
|
||||
flushSync();
|
||||
const titre = dans(m, '[data-ecran="evenement"] h1');
|
||||
expect(titre.textContent).toBe(nom);
|
||||
expect(document.documentElement.scrollWidth, motif).toBeLessThanOrEqual(window.innerWidth);
|
||||
expect(titre.getBoundingClientRect().right, motif).toBeLessThanOrEqual(window.innerWidth);
|
||||
expect(coin(fixe(m, 'modifier')), motif).toEqual(place);
|
||||
expect(visible(fixe(m, 'modifier')), motif).toBe(true);
|
||||
await m.seance.fermer();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('passer en écriture (§ 8.4, § 19.3 01b)', () => {
|
||||
test('avant « Modifier », « Bloqué » est refusé à côté de l’état avec « Modifier », sans rien écrire ; après, il écrit une entrée, un jalon, et la séance revient en lecture', async () => {
|
||||
const m = await neuf();
|
||||
const avant = m.trace.length;
|
||||
boutonEtat(m, 'bloque').click();
|
||||
const refus = await attendre(() => {
|
||||
const element = dans(m, '[data-etat="bloque"] [data-refus="MODE_LECTURE"]');
|
||||
expect(element).not.toBeNull();
|
||||
return element;
|
||||
});
|
||||
expect(refus.textContent).toContain(t('MODE_LECTURE', {}));
|
||||
expect(refus.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||||
expect(visible(refus)).toBe(true);
|
||||
expect(m.trace.length).toBe(avant);
|
||||
fixe(m, 'modifier').click();
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||||
expect(fichier(m, SUFFIXES.verrou)).not.toBeNull();
|
||||
// Sur un brouillon neuf, seul « Bloqué » pose un état : les autres sont
|
||||
// refusés à côté d'eux, sans rien écrire.
|
||||
const ecrites = m.trace.length;
|
||||
for (const [etat, code] of [
|
||||
['brouillon', 'SANS_EFFET'],
|
||||
['propose', 'AUCUNE_PROPOSITION'],
|
||||
['retenu', 'AUCUN_RETENU'],
|
||||
]) {
|
||||
boutonEtat(m, etat).click();
|
||||
await attendre(() => expect(refusPres(boutonEtat(m, etat), code)).not.toBeNull());
|
||||
}
|
||||
expect(m.trace.length).toBe(ecrites);
|
||||
const { entrees, jalons } = journalDe(m);
|
||||
boutonEtat(m, 'bloque').click();
|
||||
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('bloque'));
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||||
const apres = journalDe(m);
|
||||
expect(apres.entrees.length).toBe(entrees.length + 1);
|
||||
expect(apres.jalons.length).toBe(jalons.length + 1);
|
||||
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
|
||||
expect(fixe(m, 'debloquer')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('le cadre de mode (§ 8.4, § 14.5)', () => {
|
||||
test('il contient la surface, sa bordure n’est pas nulle, son libellé se voit ; texte et trait diffèrent en lecture, en écriture et bloqué ; même boîte après défilement', async () => {
|
||||
const m = await neuf();
|
||||
const releves = [];
|
||||
const relever = () => {
|
||||
flushSync();
|
||||
const boite = cadre(m).getBoundingClientRect();
|
||||
const surface = dans(m, '[data-section]').getBoundingClientRect();
|
||||
expect(boite.left).toBeLessThanOrEqual(surface.left);
|
||||
expect(boite.top).toBeLessThanOrEqual(surface.top);
|
||||
expect(boite.right).toBeGreaterThanOrEqual(surface.right);
|
||||
expect(boite.bottom).toBeGreaterThanOrEqual(surface.bottom);
|
||||
const style = getComputedStyle(cadre(m));
|
||||
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0);
|
||||
expect(style.borderTopStyle).not.toBe('none');
|
||||
const libelle = libelleCadre(m);
|
||||
expect(libelle.checkVisibility()).toBe(true);
|
||||
expect(visible(libelle)).toBe(true);
|
||||
expect(libelle.contains(auCentre(libelle))).toBe(true);
|
||||
expect(libelle.textContent).toBe(libelleBandeau(m).textContent);
|
||||
releves.push({ texte: libelle.textContent, trait: style.borderTopStyle });
|
||||
};
|
||||
relever();
|
||||
await m.seance.passerEnEcriture();
|
||||
relever();
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
relever();
|
||||
expect(releves.map(({ texte }) => texte)).toEqual([
|
||||
t('coquille.mode.lecture'),
|
||||
t('coquille.mode.ecriture'),
|
||||
t('coquille.mode.bloque'),
|
||||
]);
|
||||
expect(new Set(releves.map(({ trait }) => trait)).size).toBe(3);
|
||||
// Une surface plus haute que la fenêtre : la page et la surface
|
||||
// défilées jusqu'en bas, le libellé garde sa boîte.
|
||||
const avant = libelleCadre(m).getBoundingClientRect().toJSON();
|
||||
const haut = document.createElement('div');
|
||||
haut.style.height = '3000px';
|
||||
dans(m, '[data-section]').append(haut);
|
||||
window.scrollTo(0, document.documentElement.scrollHeight);
|
||||
for (let porteur = dans(m, '[data-section]'); porteur !== null; porteur = porteur.parentElement) {
|
||||
porteur.scrollTop = porteur.scrollHeight;
|
||||
}
|
||||
expect(libelleCadre(m).getBoundingClientRect().toJSON()).toEqual(avant);
|
||||
expect(visible(libelleCadre(m))).toBe(true);
|
||||
expect(visible(fixe(m, 'debloquer'))).toBe(true);
|
||||
haut.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’inactivité (§ 8.4, § 19.4)', () => {
|
||||
for (const [ouvrir, fermer] of [
|
||||
['debuterGeste', 'finirGeste'],
|
||||
['debuterCalcul', 'finirCalcul'],
|
||||
]) {
|
||||
test(`${ouvrir} ouvert, onze minutes ne ramènent pas en lecture ; ${fermer}, dix minutes y ramènent : verrou rendu, cadre, bandeau et avertissement le disent`, async () => {
|
||||
const m = await neuf();
|
||||
fixe(m, 'modifier').click();
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||||
m.seance[ouvrir]();
|
||||
m.horloge.avancer(11 * 60 * 1000);
|
||||
flushSync();
|
||||
expect(m.seance.etat().mode).toBe('ecriture');
|
||||
m.seance[fermer]();
|
||||
m.horloge.avancer(INACTIVITE_MS);
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||||
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
|
||||
expect(libelleCadre(m).textContent).toBe(t('coquille.mode.lecture'));
|
||||
expect(libelleBandeau(m).textContent).toBe(t('coquille.mode.lecture'));
|
||||
const annonce = avertissement(m, 'RETOUR_AUTOMATIQUE');
|
||||
expect(annonce.textContent).toContain(t('RETOUR_AUTOMATIQUE', {}));
|
||||
expect(visible(annonce)).toBe(true);
|
||||
expect(fixe(m, 'modifier')).not.toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('bloqué (§ 8.5, § 9)', () => {
|
||||
test('« Bloquer » rend le verrou ; une autre séance le lit bloqué ; ni « Modifier », ni « Retenu », ni « Défaire » ne débloquent ; « Débloquer » se confirme en place, puis écrit', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
boutonEtat(m, 'bloque').click();
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||||
expect(m.seance.etat().evenement.etat).toBe('bloque');
|
||||
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
|
||||
expect(fixe(m, 'debloquer')).not.toBeNull();
|
||||
const base = baseDe(m);
|
||||
const autre = await monter({ systeme: { disque: m.disque } });
|
||||
await autre.seance.ouvrir(base);
|
||||
flushSync();
|
||||
expect(autre.seance.etat().evenement.etat).toBe('bloque');
|
||||
expect(fixe(autre, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
|
||||
expect(fixe(autre, 'modifier')).toBeNull();
|
||||
|
||||
const avant = m.trace.length;
|
||||
expect(fixe(m, 'modifier')).toBeNull();
|
||||
boutonEtat(m, 'retenu').click();
|
||||
await attendre(() => expect(refusPres(boutonEtat(m, 'retenu'), 'LECTURE_SEULE')).not.toBeNull());
|
||||
historique(m, 'defaire').click();
|
||||
const refusDefaire = await attendre(() => {
|
||||
const element = refusPres(historique(m, 'defaire'), 'LECTURE_SEULE');
|
||||
expect(element).not.toBeNull();
|
||||
return element;
|
||||
});
|
||||
expect(refusDefaire.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
|
||||
expect(m.seance.etat().evenement.etat).toBe('bloque');
|
||||
expect(m.trace.length).toBe(avant);
|
||||
|
||||
// Un clic arme, sans rien écrire ; l'horloge le désarme au terme du délai.
|
||||
fixe(m, 'debloquer').click();
|
||||
flushSync();
|
||||
expect(fixe(m, 'debloquer').textContent).toBe(t(CONFIRMATION_DEBLOCAGE));
|
||||
m.horloge.avancer(DELAI_CONFIRMATION_MS);
|
||||
flushSync();
|
||||
expect(fixe(m, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
|
||||
fixe(m, 'debloquer').click();
|
||||
flushSync();
|
||||
expect(m.trace.length).toBe(avant);
|
||||
expect(m.seance.etat().evenement.etat).toBe('bloque');
|
||||
const { entrees, jalons } = journalDe(m);
|
||||
fixe(m, 'debloquer').click();
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||||
const { charge } = m.seance.etat();
|
||||
expect(charge.evenement.etat).toBe(etatDeduit(charge));
|
||||
expect(journalDe(m).entrees.length).toBe(entrees.length + 1);
|
||||
expect(journalDe(m).jalons.length).toBe(jalons.length + 1);
|
||||
expect(fixe(m, 'lecture')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('les états du plan (§ 9)', () => {
|
||||
test('avec des propositions et un retenu, « Brouillon » puis « Retenu » : rien d’autre ne change, deux entrées, deux jalons, libellés exacts', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m);
|
||||
const avant = m.seance.etat().charge;
|
||||
expect(avant.evenement.etat).toBe('retenu');
|
||||
const { entrees, jalons } = journalDe(m);
|
||||
boutonEtat(m, 'brouillon').click();
|
||||
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('brouillon'));
|
||||
boutonEtat(m, 'retenu').click();
|
||||
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('retenu'));
|
||||
const apres = m.seance.etat().charge;
|
||||
expect(apres).toEqual(avant);
|
||||
const journal = journalDe(m);
|
||||
expect(journal.entrees.slice(entrees.length).map((entree) => entree.libelle)).toEqual([
|
||||
libelle('ENTREE_CHANGER_ETAT', { etat: 'brouillon' }),
|
||||
libelle('ENTREE_CHANGER_ETAT', { etat: 'retenu' }),
|
||||
]);
|
||||
expect(journal.jalons.length).toBe(jalons.length + 2);
|
||||
expect(journal.jalons.slice(-2).map(({ nom }) => nom)).toEqual([
|
||||
libelle('JALON_ETAT', { etat: 'brouillon' }),
|
||||
libelle('JALON_ETAT', { etat: 'retenu' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('« Retenu » sans retenu : refusé à côté de l’état, avec la section des propositions pour remède', async () => {
|
||||
const m = await neuf();
|
||||
fixe(m, 'modifier').click();
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||||
const avant = m.trace.length;
|
||||
boutonEtat(m, 'retenu').click();
|
||||
const refus = await attendre(() => {
|
||||
const element = refusPres(boutonEtat(m, 'retenu'), 'AUCUN_RETENU');
|
||||
expect(element).not.toBeNull();
|
||||
return element;
|
||||
});
|
||||
expect(visible(refus)).toBe(true);
|
||||
const remede = refus.querySelector('[data-commande="ouvrirSection"]');
|
||||
expect(remede.textContent).toBe(t('communs.remede.ouvrirSection.propositions'));
|
||||
remede.click();
|
||||
await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
|
||||
expect(m.trace.length).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('défaire, refaire (§ 8.3)', () => {
|
||||
const nombre = (m) => m.seance.etat().charge.participants.length;
|
||||
|
||||
test('trois gestes, « Défaire » rend le deuxième et offre « Refaire » ; « Refaire » rend le troisième ; un geste après « Défaire » retire « Refaire »', async () => {
|
||||
const m = await neuf();
|
||||
await m.seance.passerEnEcriture();
|
||||
for (const nom of NOMS.slice(0, 3)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||||
flushSync();
|
||||
expect(historique(m, 'refaire')).toBeNull();
|
||||
historique(m, 'defaire').click();
|
||||
await attendre(() => expect(nombre(m)).toBe(2));
|
||||
expect(historique(m, 'refaire').textContent).toBe(t('evenement.commande.refaire'));
|
||||
historique(m, 'refaire').click();
|
||||
await attendre(() => expect(nombre(m)).toBe(3));
|
||||
historique(m, 'defaire').click();
|
||||
await attendre(() => expect(nombre(m)).toBe(2));
|
||||
expect(historique(m, 'refaire')).not.toBeNull();
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[3] } });
|
||||
await attendre(() => expect(historique(m, 'refaire')).toBeNull());
|
||||
});
|
||||
|
||||
test('un retour de 5 à 2, puis « Défaire » : la cible est le prédécesseur de 2 sur le fil, non la ligne précédente', async () => {
|
||||
const m = await neuf();
|
||||
await m.seance.passerEnEcriture();
|
||||
for (const nom of NOMS.slice(0, 4)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||||
expect(m.seance.etat().evenement.revision).toBe(5);
|
||||
await m.seance.revenir(2);
|
||||
flushSync();
|
||||
expect(nombre(m)).toBe(1);
|
||||
historique(m, 'defaire').click();
|
||||
await attendre(() => expect(nombre(m)).toBe(0));
|
||||
});
|
||||
|
||||
test('en lecture, « Défaire » est refusé à côté de lui avec « Modifier » ; bloqué, avec « Débloquer » ; rien ne s’écrit', async () => {
|
||||
const m = await neuf();
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } });
|
||||
await m.seance.passerEnLecture();
|
||||
flushSync();
|
||||
const avant = m.trace.length;
|
||||
historique(m, 'defaire').click();
|
||||
const enLecture = await attendre(() => {
|
||||
const element = refusPres(historique(m, 'defaire'), 'MODE_LECTURE');
|
||||
expect(element).not.toBeNull();
|
||||
return element;
|
||||
});
|
||||
expect(enLecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||||
expect(m.trace.length).toBe(avant);
|
||||
expect(nombre(m)).toBe(1);
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
flushSync();
|
||||
const bloque = m.trace.length;
|
||||
historique(m, 'defaire').click();
|
||||
const refus = await attendre(() => {
|
||||
const element = refusPres(historique(m, 'defaire'), 'LECTURE_SEULE');
|
||||
expect(element).not.toBeNull();
|
||||
return element;
|
||||
});
|
||||
expect(refus.querySelector('[data-commande="debloquer"]')).not.toBeNull();
|
||||
expect(m.trace.length).toBe(bloque);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les annonces d’ouverture (§ 8.9, § 9)', () => {
|
||||
test('une proposition de longueur fausse et une périmée : l’écartée nomme son numéro et sa faute ; la dérive nomme la périmée et sa raison, et la charge la garde', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m, { retenir: false });
|
||||
const base = baseDe(m);
|
||||
const [premiere, seconde] = m.seance.etat().charge.propositions.map(({ id }) => id);
|
||||
await m.seance.fermer();
|
||||
reecrireEtat(m, base, (charge) => {
|
||||
charge.propositions[0].placement.pop();
|
||||
charge.participants[0].exclu = true;
|
||||
});
|
||||
await m.seance.ouvrir(base);
|
||||
flushSync();
|
||||
const etat = m.seance.etat();
|
||||
expect(etat.fautives.length).toBe(1);
|
||||
const [fautive] = etat.fautives;
|
||||
expect(fautive.id).toBe(premiere);
|
||||
const ecartee = avertissement(m, 'PROPOSITIONS_FAUTIVES');
|
||||
expect(visible(ecartee)).toBe(true);
|
||||
expect(ecartee.textContent).toContain(t('PROPOSITIONS_FAUTIVES', { ids: [fautive.id] }));
|
||||
expect(fautive.fautes.length).toBeGreaterThan(0);
|
||||
for (const faute of fautive.fautes) expect(ecartee.textContent).toContain(t(faute.code, faute));
|
||||
expect(ecartee.querySelector('[data-fautive]').textContent).toContain(String(fautive.id));
|
||||
const derive = avertissement(m, 'DERIVE');
|
||||
expect(derive.textContent).toContain(t('DERIVE', { ids: [seconde], retenu: false }));
|
||||
const [{ raisons }] = etat.examen.derives;
|
||||
expect(raisons.length).toBeGreaterThan(0);
|
||||
for (const raison of raisons) expect(derive.textContent).toContain(t(raison.code, raison));
|
||||
expect(etat.charge.propositions.map(({ id }) => id)).toEqual([seconde]);
|
||||
derive.querySelector('[data-commande="ouvrirSection"]').click();
|
||||
await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
|
||||
});
|
||||
|
||||
test('un retenu fautif : son message vient en premier et offre « Ouvrir la version précédente », qui l’ouvre en lecture sans rien écrire', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m);
|
||||
const base = baseDe(m);
|
||||
await m.seance.fermer();
|
||||
reecrireEtat(m, base, (charge) => {
|
||||
charge.retenu.placement.pop();
|
||||
});
|
||||
await m.seance.ouvrir(base);
|
||||
flushSync();
|
||||
const premier = dans(m, '[data-avertissement]');
|
||||
expect(premier.dataset.avertissement).toBe('RETENU_FAUTIF');
|
||||
expect(premier.textContent).toContain(t('RETENU_FAUTIF', {}));
|
||||
const remede = premier.querySelector('[data-commande="ouvrirSecours"]');
|
||||
expect(remede.textContent).toBe(t('communs.remede.ouvrirSecours.precedent'));
|
||||
const avant = m.trace.length;
|
||||
remede.click();
|
||||
await attendre(() => expect(m.seance.etat().examen.retenu.fautes).toEqual([]));
|
||||
expect(m.seance.etat().mode).toBe('lecture');
|
||||
expect(m.seance.etat().charge.retenu).toBeNull();
|
||||
expect(avertissement(m, 'RETENU_FAUTIF')).toBeNull();
|
||||
expect(m.trace.length).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le journal (§ 8.6, § 8.2)', () => {
|
||||
test('cent entrées, la 37ᵉ abîmée : JOURNAL_ECARTEES {n: 64} s’affiche, son texte porte « 64 »', async () => {
|
||||
const m = await neuf();
|
||||
await m.seance.passerEnEcriture();
|
||||
for (let n = 2; n <= 100; n += 1) await m.seance.executer('ajouterParticipant', { champs: { nom: `Ombrelle ${n}` } });
|
||||
const base = baseDe(m);
|
||||
const journal = journalDe(m);
|
||||
expect(journal.entrees.length).toBe(100);
|
||||
expect(journal.jalons.length).toBe(1);
|
||||
await m.seance.fermer();
|
||||
const chemin = `${base}${SUFFIXES.journal}`;
|
||||
const lignes = m.fs.contenu('documents', chemin).split('\n');
|
||||
const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === 37);
|
||||
expect(rang).toBeGreaterThan(0);
|
||||
lignes[rang] = '{"type":"entree", abîmée';
|
||||
m.fs.deposer('documents', chemin, lignes.join('\n'));
|
||||
const avant = m.trace.length;
|
||||
await m.seance.ouvrir(base);
|
||||
flushSync();
|
||||
const ecartees = avertissement(m, 'JOURNAL_ECARTEES');
|
||||
expect(ecartees.textContent).toContain(t('JOURNAL_ECARTEES', { n: 64 }));
|
||||
expect(ecartees.textContent).toContain('64');
|
||||
expect(visible(ecartees)).toBe(true);
|
||||
expect(m.trace.length).toBe(avant);
|
||||
});
|
||||
|
||||
test('un journal en avance d’une entrée : la charge du geste est celle qui s’ouvre, annoncée, sans rien écrire', async () => {
|
||||
const m = await neuf();
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } });
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[1] } });
|
||||
const base = baseDe(m);
|
||||
await m.seance.fermer();
|
||||
m.fs.deposer('documents', `${base}${SUFFIXES.etat}`, m.fs.contenu('documents', `${base}${SUFFIXES.precedent}`));
|
||||
const avant = m.trace.length;
|
||||
await m.seance.ouvrir(base);
|
||||
flushSync();
|
||||
expect(m.seance.etat().charge.participants.map(({ nom }) => nom)).toEqual(NOMS.slice(0, 2));
|
||||
expect(m.seance.etat().evenement.revision).toBe(3);
|
||||
expect(avertissement(m, 'JOURNAL_REPRIS').textContent).toContain(t('JOURNAL_REPRIS', {}));
|
||||
expect(m.trace.length).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la dérive (§ 9)', () => {
|
||||
test('retenu, puis exclure une personne : l’avertissement nomme le retenu et la raison, et le geste suivant reste permis', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m);
|
||||
const exclue = m.seance.etat().charge.participants[0].id;
|
||||
await m.seance.executer('exclureParticipant', { id: exclue });
|
||||
flushSync();
|
||||
const nouvelle = m.seance.etat().avertissements.find(({ code }) => code === 'DERIVE_NOUVELLE');
|
||||
expect(nouvelle.details.touchees.map(({ id }) => id)).toContain('retenu');
|
||||
const element = avertissement(m, 'DERIVE_NOUVELLE');
|
||||
expect(element.textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
|
||||
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', { participant: exclue }));
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
|
||||
expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1);
|
||||
});
|
||||
|
||||
test('retenu : supprimer une table, réduire une capacité, ajouter une personne, chacun accepté, l’avertissement nommant les propositions touchées ; leur nombre inchangé ; un import, IMPORT_EN_RETENU', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m);
|
||||
const propositions = m.seance.etat().charge.propositions.length;
|
||||
const [premiere, seconde] = m.seance.etat().charge.tables;
|
||||
const gestes = [
|
||||
['supprimerTable', { id: premiere.id }],
|
||||
['changerPlacesTable', { id: seconde.id, sieges: 2, origine: 'champ' }],
|
||||
['ajouterParticipant', { champs: { nom: 'Escarbille' } }],
|
||||
];
|
||||
for (const [nom, args] of gestes) {
|
||||
await m.seance.executer(nom, args);
|
||||
flushSync();
|
||||
const nouvelle = m.seance.etat().avertissements.find(({ code }) => code === 'DERIVE_NOUVELLE');
|
||||
expect(nouvelle, nom).toBeDefined();
|
||||
expect(nouvelle.details.touchees.length, nom).toBeGreaterThan(0);
|
||||
expect(avertissement(m, 'DERIVE_NOUVELLE').textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
|
||||
expect(m.seance.etat().charge.propositions.length).toBe(propositions);
|
||||
}
|
||||
const apercu = apercevoir('nom\nTilleraie\n', { participants: m.seance.etat().charge.participants });
|
||||
await m.seance.executer('importerParticipants', { apercu, mode: 'ajouter' });
|
||||
flushSync();
|
||||
expect(avertissement(m, 'IMPORT_EN_RETENU').textContent).toContain(t('IMPORT_EN_RETENU', {}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('le verrou d’une autre séance (§ 8.8)', () => {
|
||||
test('le second montage ouvre en lecture ; son « Modifier » est refusé, la séance et l’instant affichés ; le premier coupé, il reprend le verrou du mort', async () => {
|
||||
const premier = await neuf();
|
||||
await premier.seance.passerEnEcriture();
|
||||
const base = baseDe(premier);
|
||||
const tenu = JSON.parse(fichier(premier, SUFFIXES.verrou));
|
||||
const second = await monter({ systeme: { disque: premier.disque } });
|
||||
// Les deux montages s'empilent dans la page : le premier se cache, et
|
||||
// le second occupe la fenêtre.
|
||||
premier.racine.hidden = true;
|
||||
await second.seance.ouvrir(base);
|
||||
flushSync();
|
||||
expect(second.seance.etat().mode).toBe('lecture');
|
||||
expect(dans(second, '[data-refus]')).toBeNull();
|
||||
fixe(second, 'modifier').click();
|
||||
const refus = await attendre(() => {
|
||||
const element = refusPres(fixe(second, 'modifier'), 'VERROU_PRIS');
|
||||
expect(element).not.toBeNull();
|
||||
return element;
|
||||
});
|
||||
const pris = second.seance.etat().verrouAutre;
|
||||
expect(pris).toEqual({ seance: tenu.seance, depuis: tenu.depuis, vivant: true });
|
||||
expect(refus.textContent).toContain(t('VERROU_PRIS', { ...pris, remede: null }));
|
||||
const verrou = dans(second, '[data-verrou]');
|
||||
expect(visible(verrou)).toBe(true);
|
||||
expect(dans(second, '[data-verrou-seance]').textContent).toBe(tenu.seance);
|
||||
expect(dans(second, '[data-verrou-depuis]').textContent).toBe(tenu.depuis);
|
||||
expect(verrou.querySelector('[data-commande="reprendreVerrou"]')).toBeNull();
|
||||
premier.fs.pannes.couperApres(0);
|
||||
fixe(second, 'modifier').click();
|
||||
await attendre(() => expect(second.seance.etat().mode).toBe('ecriture'));
|
||||
const repris = avertissement(second, 'VERROU_REPRIS');
|
||||
expect(repris.textContent).toContain(t('VERROU_REPRIS', { seance: tenu.seance, depuis: tenu.depuis }));
|
||||
expect(JSON.parse(fichier(second, SUFFIXES.verrou)).seance).toBe(second.seance.id);
|
||||
expect(dans(second, '[data-verrou]')).toBeNull();
|
||||
});
|
||||
|
||||
test('le verrou d’un autre poste : la reprise s’offre à côté du refus, se confirme en place en nommant la séance et l’instant, puis le reprend', async () => {
|
||||
const premier = await neuf();
|
||||
await premier.seance.passerEnEcriture();
|
||||
const base = baseDe(premier);
|
||||
const tenu = JSON.parse(fichier(premier, SUFFIXES.verrou));
|
||||
const second = await monter({ systeme: { disque: premier.disque, hote: 'poste-voisin' } });
|
||||
premier.racine.hidden = true;
|
||||
await second.seance.ouvrir(base);
|
||||
flushSync();
|
||||
fixe(second, 'modifier').click();
|
||||
await attendre(() => expect(refusPres(fixe(second, 'modifier'), 'VERROU_PRIS')).not.toBeNull());
|
||||
expect(second.seance.etat().verrouAutre.vivant).toBeNull();
|
||||
const verrou = dans(second, '[data-verrou]');
|
||||
expect(dans(second, '[data-verrou-seance]').textContent).toBe(tenu.seance);
|
||||
expect(dans(second, '[data-verrou-depuis]').textContent).toBe(tenu.depuis);
|
||||
const reprendre = () => dans(second, '[data-verrou] [data-commande="reprendreVerrou"]');
|
||||
expect(reprendre().textContent).toBe(t('communs.remede.reprendreVerrou'));
|
||||
expect(visible(reprendre())).toBe(true);
|
||||
const avant = second.trace.length;
|
||||
reprendre().click();
|
||||
flushSync();
|
||||
expect(reprendre().textContent).toBe(t('evenement.verrou.confirmerReprise'));
|
||||
expect(verrou.contains(reprendre())).toBe(true);
|
||||
expect(second.trace.length).toBe(avant);
|
||||
expect(second.seance.etat().mode).toBe('lecture');
|
||||
reprendre().click();
|
||||
await attendre(() => expect(second.seance.etat().mode).toBe('ecriture'));
|
||||
expect(JSON.parse(fichier(second, SUFFIXES.verrou)).seance).toBe(second.seance.id);
|
||||
expect(avertissement(second, 'VERROU_REPRIS').textContent).toContain(
|
||||
t('VERROU_REPRIS', { seance: tenu.seance, depuis: tenu.depuis }),
|
||||
);
|
||||
expect(dans(second, '[data-verrou]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('le support rompu (§ 8.6, § 8.2)', () => {
|
||||
const message = (m) => dans(m, '[data-support-rompu]');
|
||||
const commandeListe = (m) => dans(m, '[data-bandeau] [data-commande="liste"]');
|
||||
|
||||
// L'événement ouvert en écriture, puis le support de racine refuse
|
||||
// l'écriture d'un geste.
|
||||
async function rompre(m, racine) {
|
||||
fixe(m, 'modifier').click();
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||||
m.fs.pannes.ecriture(racine);
|
||||
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' });
|
||||
flushSync();
|
||||
}
|
||||
|
||||
test('publié : le message nomme le fichier, le dossier et la rupture ; « Liste » est refusée ; « Écrire dans les Documents » y écrit, et la séance s’y poursuit', async () => {
|
||||
const m = await neuf({ systeme: { executable: 'E:\\soirees' } });
|
||||
expect(m.seance.etat().racine.id).toBe('portable');
|
||||
const base = baseDe(m);
|
||||
await rompre(m, 'portable');
|
||||
const rompu = m.seance.etat().supportRompu;
|
||||
expect(rompu.ailleurs).toEqual({ id: 'documents', chemin: DOCUMENTS });
|
||||
expect(visible(message(m))).toBe(true);
|
||||
const { ailleurs, ...details } = rompu;
|
||||
expect(message(m).textContent).toContain(t('SUPPORT_ROMPU', details));
|
||||
expect(message(m).textContent).toContain(rompu.chemin);
|
||||
expect(message(m).textContent).toContain(rompu.dossier);
|
||||
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[1] } })).rejects.toMatchObject({
|
||||
code: 'SUPPORT_ROMPU',
|
||||
});
|
||||
// Le refus posé dans le bandeau ne déplace pas la place fixe, et ne la
|
||||
// couvre pas.
|
||||
const place = coin(fixe(m, 'lecture'));
|
||||
commandeListe(m).click();
|
||||
await attendre(() => expect(dans(m, '[data-refus="liste"] [data-refus="SUPPORT_ROMPU_NON_SAUVE"]')).not.toBeNull());
|
||||
expect(coin(fixe(m, 'lecture'))).toEqual(place);
|
||||
expect(visible(fixe(m, 'lecture'))).toBe(true);
|
||||
expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true);
|
||||
expect(m.seance.etat().evenement.base).toBe(base);
|
||||
expect(message(m).querySelector('[data-commande="choisirDossier"]')).not.toBeNull();
|
||||
message(m).querySelector('[data-commande="ecrireDocuments"]').click();
|
||||
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
|
||||
const nouvelle = baseDe(m);
|
||||
expect(m.fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)).not.toBeNull();
|
||||
const entrees = journalDe(m, 'documents').entrees.length;
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[2] } });
|
||||
expect(journalDe(m, 'documents').entrees.length).toBe(entrees + 1);
|
||||
expect(message(m)).toBeNull();
|
||||
commandeListe(m).click();
|
||||
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
|
||||
expect(m.seance.etat().racine.id).toBe('documents');
|
||||
await attendre(() => expect(dans(m, `[data-evenement="${nouvelle}"]`)).not.toBeNull());
|
||||
const dossier = dans(m, '[data-dossier-travail]');
|
||||
expect(dossier.textContent).toBe(DOCUMENTS);
|
||||
expect(visible(dossier)).toBe(true);
|
||||
});
|
||||
|
||||
test('non publié : le message n’offre que « Choisir un dossier… » ; le dialogue annulé ne change rien', async () => {
|
||||
const m = await neuf();
|
||||
await rompre(m, 'documents');
|
||||
expect(m.seance.etat().supportRompu.ailleurs).toBeNull();
|
||||
expect(message(m).querySelector('[data-commande="ecrireDocuments"]')).toBeNull();
|
||||
const choisir = message(m).querySelector('[data-commande="choisirDossier"]');
|
||||
expect(choisir.textContent).toBe(t('communs.commande.choisirDossier'));
|
||||
choisir.click();
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull());
|
||||
expect(visible(message(m))).toBe(true);
|
||||
});
|
||||
|
||||
test('non publié, « Fermer sans écrire ailleurs », confirmé en place : la liste revient, sans aucune écriture', async () => {
|
||||
const m = await neuf();
|
||||
await rompre(m, 'documents');
|
||||
const avant = m.trace.length;
|
||||
const abandonner = () => message(m).querySelector('[data-commande="abandonner"]');
|
||||
expect(abandonner().textContent).toBe(t('evenement.supportRompu.abandonner'));
|
||||
abandonner().click();
|
||||
flushSync();
|
||||
expect(abandonner().textContent).toBe(t('evenement.supportRompu.confirmerAbandon'));
|
||||
expect(m.seance.etat().evenement).not.toBeNull();
|
||||
abandonner().click();
|
||||
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
|
||||
expect(m.seance.etat().evenement).toBeNull();
|
||||
expect(m.trace.slice(avant).filter(([primitive]) => primitive !== 'deverrouiller')).toEqual([]);
|
||||
});
|
||||
|
||||
test('non publié, un dossier choisi : état et journal y sont écrits, le geste suivant aussi, et la liste le suit', async () => {
|
||||
const m = await neuf({ systeme: { dossiersChoisis: ['D:\\Secours'] } });
|
||||
await rompre(m, 'documents');
|
||||
message(m).querySelector('[data-commande="choisirDossier"]').click();
|
||||
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
|
||||
expect(m.seance.etat().racine).toEqual({ id: 'choisi-1', chemin: 'D:\\Secours' });
|
||||
const base = baseDe(m);
|
||||
expect(m.fs.contenu('choisi-1', `${base}${SUFFIXES.etat}`)).not.toBeNull();
|
||||
const entrees = journalDe(m, 'choisi-1').entrees.length;
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[3] } });
|
||||
expect(journalDe(m, 'choisi-1').entrees.length).toBe(entrees + 1);
|
||||
commandeListe(m).click();
|
||||
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
|
||||
await attendre(() => expect(dans(m, `[data-evenement="${base}"]`)).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('le format plus récent (§ 8.8)', () => {
|
||||
test('« Modifier » est remplacé par l’explication, et aucune commande de l’événement ne passe en écriture', async () => {
|
||||
const m = await neuf();
|
||||
const base = baseDe(m);
|
||||
await m.seance.fermer();
|
||||
const chemin = `${base}${SUFFIXES.etat}`;
|
||||
const texte = m.fs.contenu('documents', chemin);
|
||||
m.fs.deposer('documents', chemin, texte.replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`));
|
||||
await m.seance.ouvrir(base);
|
||||
flushSync();
|
||||
expect(fixe(m, 'modifier')).toBeNull();
|
||||
const explication = dans(m, '[data-place-fixe] [data-refus="LECTURE_SEULE"]');
|
||||
expect(explication.textContent).toContain(t('LECTURE_SEULE', { raison: 'FORMAT', remede: null }));
|
||||
expect(visible(explication)).toBe(true);
|
||||
const avant = m.trace.length;
|
||||
const boutons = tous(m, '[data-ecran="evenement"] button').filter((bouton) => bouton.dataset.commande !== 'section');
|
||||
expect(boutons.length).toBeGreaterThan(0);
|
||||
for (const bouton of boutons) {
|
||||
if (!bouton.isConnected) continue;
|
||||
bouton.click();
|
||||
bouton.click();
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||||
expect(m.trace.length).toBe(avant);
|
||||
});
|
||||
});
|
||||
125
src/interface/evenement/Evenement.svelte
Normal file
125
src/interface/evenement/Evenement.svelte
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// L'événement ouvert (§ 2.1, § 8.4) : le cadre de mode, qui entoure la
|
||||
// surface de travail de toute section, et porte sous sa barre — place
|
||||
// fixe, libellé, Défaire et Refaire — la tête de l'événement : son en-tête
|
||||
// — nom et états du plan — et la navigation des sections, dans l'ordre du
|
||||
// § 2.1. Rien ne précède le cadre dans l'écran : ce que la tête montre ne
|
||||
// déplace pas la place fixe. Aucune section n'en exige une autre : la
|
||||
// navigation les offre toutes, et la séance refuse elle-même ce qui ne
|
||||
// peut pas se faire. Le plein écran du plan efface la tête, jamais le
|
||||
// cadre ni sa place fixe.
|
||||
//
|
||||
// L'écran de l'événement tient dans la fenêtre, sous le bandeau : la page
|
||||
// ne défile pas, la surface de travail défile dans le cadre, et le cadre,
|
||||
// son libellé et sa place fixe restent où ils sont (§ 8.4). La règle
|
||||
// globale ci-dessous donne pour cela une hauteur à l'ascendance de l'écran
|
||||
// — le conteneur de l'application et son <main> — tant qu'il porte cet
|
||||
// écran, et à lui seul.
|
||||
import { getContext } from 'svelte';
|
||||
import Diagnostic from '../diagnostic/Diagnostic.svelte';
|
||||
import SectionGeneration from '../generation/SectionGeneration.svelte';
|
||||
import SectionImport from '../import/SectionImport.svelte';
|
||||
import SectionParticipants from '../participants/SectionParticipants.svelte';
|
||||
import SectionPlan from '../plan/SectionPlan.svelte';
|
||||
import SectionPropositions from '../propositions/SectionPropositions.svelte';
|
||||
import SectionTables from '../tables/SectionTables.svelte';
|
||||
import Avertissements from './Avertissements.svelte';
|
||||
import CadreMode from './CadreMode.svelte';
|
||||
import EnteteEvenement from './EnteteEvenement.svelte';
|
||||
import SupportRompu from './SupportRompu.svelte';
|
||||
import { SECTIONS, cleDeSection } from './sections.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
const seance = getContext('seance');
|
||||
|
||||
// Le composant de chaque section.
|
||||
const COMPOSANTS = new Map([
|
||||
['import', SectionImport],
|
||||
['participants', SectionParticipants],
|
||||
['tables', SectionTables],
|
||||
['plan', SectionPlan],
|
||||
['diagnostic', Diagnostic],
|
||||
['generation', SectionGeneration],
|
||||
['propositions', SectionPropositions],
|
||||
]);
|
||||
|
||||
const Section = $derived(COMPOSANTS.get($ui.section));
|
||||
</script>
|
||||
|
||||
{#snippet tete()}
|
||||
<EnteteEvenement />
|
||||
<nav aria-label={t('coquille.navigation')}>
|
||||
{#each SECTIONS as nom (nom)}
|
||||
<button
|
||||
type="button"
|
||||
data-commande="section"
|
||||
data-cible={nom}
|
||||
aria-current={$ui.section === nom ? 'page' : undefined}
|
||||
onclick={() => ui.allerA(nom)}>{t(cleDeSection(nom))}</button
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{/snippet}
|
||||
|
||||
<div class="evenement" data-ecran="evenement">
|
||||
<CadreMode tete={$ui.pleinEcran ? null : tete}>
|
||||
{#if $seance.supportRompu !== null}
|
||||
<SupportRompu />
|
||||
{/if}
|
||||
<Avertissements />
|
||||
<Section />
|
||||
</CadreMode>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* L'ascendance de l'écran de l'événement prend la hauteur de la fenêtre :
|
||||
le bandeau en haut, l'écran dessous, qui ne déborde pas. */
|
||||
:global(div:has(> main > [data-ecran='evenement'])) {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
:global(main:has(> [data-ecran='evenement'])) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.evenement {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* La section ouverte se distingue par sa forme : texte souligné et gras. */
|
||||
button[aria-current='page'] {
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
72
src/interface/evenement/SupportRompu.svelte
Normal file
72
src/interface/evenement/SupportRompu.svelte
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le support rompu (§ 8.6) : le texte du refus SUPPORT_ROMPU, qui nomme le
|
||||
// fichier, le dossier et la cause, et dit que l'historique et l'état ne se
|
||||
// correspondent plus sur ce support ; puis les issues. « Écrire dans les
|
||||
// Documents » quand la séance propose la racine documents (ailleurs) ;
|
||||
// toujours « Choisir un dossier… », seance.ecrireAilleurs(null), qui
|
||||
// demande le dossier à l'opérateur — un refus, NON_DISPONIBLE sous web,
|
||||
// s'affiche à côté de la commande ; et « Fermer sans écrire ailleurs »,
|
||||
// confirmé en place, qui abandonne l'événement à ce support.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
||||
const rompu = $derived($seance.supportRompu);
|
||||
const refus = $derived(
|
||||
rompu === null
|
||||
? null
|
||||
: { echec: rompu.echec, chemin: rompu.chemin, dossier: rompu.dossier, cause: rompu.cause },
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if rompu !== null}
|
||||
<section class="rompu" role="status" data-support-rompu>
|
||||
<p>{t('SUPPORT_ROMPU', refus)}</p>
|
||||
<div class="issues">
|
||||
{#if rompu.ailleurs !== null}
|
||||
<Commande
|
||||
cle="communs.commande.ecrireDocuments"
|
||||
nom="ecrireDocuments"
|
||||
executer={() => seance.ecrireAilleurs(rompu.ailleurs)}
|
||||
/>
|
||||
{/if}
|
||||
<Commande cle="communs.commande.choisirDossier" nom="choisirDossier" executer={() => seance.ecrireAilleurs(null)} />
|
||||
<Commande
|
||||
cle="evenement.supportRompu.abandonner"
|
||||
nom="abandonner"
|
||||
confirmer="evenement.supportRompu.confirmerAbandon"
|
||||
executer={() => seance.fermer({ abandonner: true })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.rompu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
margin-bottom: var(--espace-3);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-left: 3px solid var(--couleur-refus);
|
||||
color: var(--couleur-refus);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.issues {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
</style>
|
||||
134
src/interface/evenement/modele.js
Normal file
134
src/interface/evenement/modele.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'événement ouvert, pur : ce que ses composants montrent de
|
||||
// l'état observable de la séance (§ 8.4, § 8.5, § 8.8, § 8.9, § 9). Le cadre
|
||||
// de mode et sa forme, la commande de la place fixe, les états du plan et
|
||||
// leurs clés, la reprise de verrou que l'écran offre, et les avertissements
|
||||
// dans l'ordre de la séance, chacun avec son remède. Rien n'y est décidé
|
||||
// que la séance ne décide déjà : le refus courant, le mode et l'état du plan
|
||||
// se lisent dans EtatSeance, jamais se recalculent.
|
||||
import { cleDeMode } from '../mode.js';
|
||||
|
||||
// La forme du cadre pour chaque clé du libellé de mode : un trait différent
|
||||
// par mode, pour qu'aucun ne se distingue par la seule couleur (§ 14.5).
|
||||
const FORMES = new Map([
|
||||
['coquille.mode.lecture', 'tirets'],
|
||||
['coquille.mode.ecriture', 'plein'],
|
||||
['coquille.mode.bloque', 'double'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Libellé et forme du cadre de mode : cle, celle de cleDeMode (mode.js) — le
|
||||
* libellé du bandeau — ; forme : lecture (tirets), écriture (trait plein),
|
||||
* bloqué (trait double), quel que soit le mode d'un plan bloqué. null sans
|
||||
* événement.
|
||||
*
|
||||
* @param {Object} etatSeance EtatSeance
|
||||
* @returns {null|{cle: string, forme: 'tirets'|'plein'|'double'}}
|
||||
*/
|
||||
export function cadreDeMode(etatSeance) {
|
||||
const cle = cleDeMode(etatSeance);
|
||||
if (cle === null) return null;
|
||||
return { cle, forme: FORMES.get(cle) };
|
||||
}
|
||||
|
||||
/**
|
||||
* La commande de la place fixe (§ 8.4, § 8.5, § 8.8) : 'format' quand le
|
||||
* refus courant est celui d'un format plus récent — l'explication remplace
|
||||
* alors toute commande, plan bloqué compris ; 'debloquer' sur un plan
|
||||
* bloqué ; 'lecture' — « Revenir en lecture » — en écriture ; 'modifier'
|
||||
* sinon. null sans événement.
|
||||
*
|
||||
* @param {Object} etatSeance EtatSeance
|
||||
* @returns {null|'format'|'debloquer'|'lecture'|'modifier'}
|
||||
*/
|
||||
export function commandeFixe(etatSeance) {
|
||||
if (etatSeance.evenement === null) return null;
|
||||
const { refus } = etatSeance;
|
||||
if (refus?.code === 'LECTURE_SEULE' && refus.details?.raison === 'FORMAT') return 'format';
|
||||
if (etatSeance.evenement.etat === 'bloque') return 'debloquer';
|
||||
return etatSeance.mode === 'ecriture' ? 'lecture' : 'modifier';
|
||||
}
|
||||
|
||||
/** Les états d'un plan (§ 9), dans l'ordre du spec. */
|
||||
export const ETATS_PLAN = Object.freeze(['brouillon', 'propose', 'retenu', 'bloque']);
|
||||
|
||||
/**
|
||||
* La clé du libellé d'un état du plan, evenement.etat.<etat>.
|
||||
*
|
||||
* @param {string} etat
|
||||
* @returns {string}
|
||||
* @throws {RangeError} hors d'ETATS_PLAN
|
||||
*/
|
||||
export function cleEtat(etat) {
|
||||
if (!ETATS_PLAN.includes(etat)) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`);
|
||||
return `evenement.etat.${etat}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le verrou que l'écran offre de reprendre (§ 8.8) : en lecture, celui qui a
|
||||
* refusé le dernier passage en écriture quand sa vie ne se prouve pas
|
||||
* (vivant null) — un autre poste, un verrou illisible. Un verrou vivant ne
|
||||
* se reprend jamais, et celui d'une séance morte, la séance le reprend
|
||||
* seule. null sinon.
|
||||
*
|
||||
* @param {Object} etatSeance EtatSeance
|
||||
* @returns {null|{seance: string|null, depuis: string|null, vivant: null}}
|
||||
*/
|
||||
export function repriseOfferte(etatSeance) {
|
||||
const { evenement, mode, verrouAutre } = etatSeance;
|
||||
if (evenement === null || mode !== 'lecture' || verrouAutre === null || verrouAutre.vivant !== null) return null;
|
||||
return verrouAutre;
|
||||
}
|
||||
|
||||
// Le remède que l'écran joint à un avertissement : rouvrir la version
|
||||
// précédente d'un retenu fautif ; ouvrir les propositions devant une dérive,
|
||||
// et les tables devant des places manquantes. Les autres n'en ont pas.
|
||||
const VERS_PROPOSITIONS = Object.freeze({ geste: 'ouvrirSection', section: 'propositions' });
|
||||
const REMEDES = new Map([
|
||||
['RETENU_FAUTIF', Object.freeze({ geste: 'ouvrirSecours', source: 'precedent' })],
|
||||
['DERIVE', VERS_PROPOSITIONS],
|
||||
['DERIVE_NOUVELLE', VERS_PROPOSITIONS],
|
||||
['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })],
|
||||
]);
|
||||
|
||||
// Ce que l'examen de la séance dit de chaque placement que nomme une dérive
|
||||
// {ids, retenu} : le retenu d'abord, quand la dérive le nomme, puis chaque
|
||||
// proposition de ids, dans l'ordre de ids, avec ses raisons. Un placement
|
||||
// que l'examen ne porte pas n'apparaît pas.
|
||||
function derivesNommees({ ids = [], retenu = false }, examen) {
|
||||
if (examen === null || examen === undefined) return [];
|
||||
const nommees = [];
|
||||
if (retenu && examen.retenu.raisons.length > 0) nommees.push({ id: 'retenu', raisons: examen.retenu.raisons });
|
||||
for (const id of ids) {
|
||||
const derive = examen.derives.find((candidate) => candidate.id === id);
|
||||
if (derive !== undefined) nommees.push({ id, raisons: derive.raisons });
|
||||
}
|
||||
return nommees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les avertissements à montrer, dans l'ordre d'EtatSeance.avertissements —
|
||||
* celui de la séance : ceux de l'Ouvert courant, INVARIANT_VIOLE, ceux du
|
||||
* dernier geste, RETOUR_AUTOMATIQUE —, sans en retirer ni en ajouter. Chacun
|
||||
* garde son code et ses détails, et reçoit son remède : RETENU_FAUTIF →
|
||||
* { geste: 'ouvrirSecours', source: 'precedent' } ; DERIVE et
|
||||
* DERIVE_NOUVELLE → { geste: 'ouvrirSection', section: 'propositions' } ;
|
||||
* PLACES_MANQUANTES → { geste: 'ouvrirSection', section: 'tables' } ; null
|
||||
* pour les autres. PROPOSITIONS_FAUTIVES porte en plus fautives, celles
|
||||
* d'EtatSeance.fautives ; DERIVE, derives, ce que l'examen dit de chaque
|
||||
* placement qu'elle nomme ({id: number|'retenu', raisons}). Le texte vient
|
||||
* de la traduction, sur le code.
|
||||
*
|
||||
* @param {Object} etatSeance EtatSeance
|
||||
* @returns {Array<{code: string, details: Object, remede: Object|null, fautives?: Object[], derives?: Object[]}>}
|
||||
*/
|
||||
export function avertissementsOrdonnes(etatSeance) {
|
||||
return etatSeance.avertissements.map(({ code, details }) => {
|
||||
const rendu = { code, details, remede: REMEDES.get(code) ?? null };
|
||||
if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives;
|
||||
if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen);
|
||||
return rendu;
|
||||
});
|
||||
}
|
||||
218
src/interface/evenement/modele.test.js
Normal file
218
src/interface/evenement/modele.test.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'événement ouvert (§ 8.4, § 8.5, § 8.8, § 8.9, § 9) : le cadre
|
||||
// de mode et sa forme, la commande de la place fixe, les états du plan et
|
||||
// leurs clés, la reprise de verrou offerte, et les avertissements ordonnés,
|
||||
// chacun avec son remède. Chaque épreuve part d'un EtatSeance réduit à ce que
|
||||
// la fonction lit ; les noms et identifiants sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { ETATS_PLAN, avertissementsOrdonnes, cadreDeMode, cleEtat, commandeFixe, repriseOfferte } from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
const FORMAT_REFUS = { code: 'LECTURE_SEULE', details: { raison: 'FORMAT', remede: null } };
|
||||
const BLOQUE_REFUS = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } };
|
||||
|
||||
// Un EtatSeance réduit : mode, événement (null, ou son état du plan), refus,
|
||||
// avertissements, fautives, examen, verrouAutre.
|
||||
function etat({
|
||||
mode = 'lecture',
|
||||
plan = 'brouillon',
|
||||
refus = null,
|
||||
avertissements = [],
|
||||
fautives = [],
|
||||
examen = null,
|
||||
verrouAutre = null,
|
||||
} = {}) {
|
||||
return {
|
||||
mode,
|
||||
evenement:
|
||||
plan === null ? null : { base: 'merles', id: 'evt-merles', nom: 'Club des Merles', etat: plan, revision: 3, annonceFormat: null },
|
||||
refus,
|
||||
avertissements,
|
||||
fautives,
|
||||
examen,
|
||||
verrouAutre,
|
||||
};
|
||||
}
|
||||
|
||||
describe('cadreDeMode (§ 8.4, § 14.5)', () => {
|
||||
test('null sans événement', () => {
|
||||
assert.equal(cadreDeMode(etat({ plan: null })), null);
|
||||
});
|
||||
|
||||
test('lecture : tirets ; écriture : trait plein ; bloqué : trait double, quel que soit le mode', () => {
|
||||
assert.deepEqual(cadreDeMode(etat({ mode: 'lecture' })), { cle: 'coquille.mode.lecture', forme: 'tirets' });
|
||||
assert.deepEqual(cadreDeMode(etat({ mode: 'ecriture' })), { cle: 'coquille.mode.ecriture', forme: 'plein' });
|
||||
assert.deepEqual(cadreDeMode(etat({ mode: 'lecture', plan: 'bloque' })), { cle: 'coquille.mode.bloque', forme: 'double' });
|
||||
assert.deepEqual(cadreDeMode(etat({ mode: 'ecriture', plan: 'bloque' })), { cle: 'coquille.mode.bloque', forme: 'double' });
|
||||
});
|
||||
|
||||
test('trois formes distinctes pour trois libellés distincts, chaque libellé traduit', () => {
|
||||
const cadres = [etat(), etat({ mode: 'ecriture' }), etat({ plan: 'bloque' })].map(cadreDeMode);
|
||||
assert.equal(new Set(cadres.map(({ forme }) => forme)).size, 3);
|
||||
assert.equal(new Set(cadres.map(({ cle }) => t(cle))).size, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandeFixe (§ 8.4, § 8.5, § 8.8)', () => {
|
||||
test('null sans événement ; modifier en lecture ; lecture en écriture ; debloquer sur un plan bloqué', () => {
|
||||
assert.equal(commandeFixe(etat({ plan: null })), null);
|
||||
assert.equal(commandeFixe(etat()), 'modifier');
|
||||
assert.equal(commandeFixe(etat({ mode: 'ecriture', plan: 'retenu' })), 'lecture');
|
||||
assert.equal(commandeFixe(etat({ plan: 'bloque', refus: BLOQUE_REFUS })), 'debloquer');
|
||||
});
|
||||
|
||||
test("un format plus récent remplace la commande par l'explication, avant le plan bloqué", () => {
|
||||
assert.equal(commandeFixe(etat({ refus: FORMAT_REFUS })), 'format');
|
||||
assert.equal(commandeFixe(etat({ plan: 'bloque', refus: FORMAT_REFUS })), 'format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('les états du plan (§ 9)', () => {
|
||||
test("les quatre états, dans l'ordre du § 9, figés, chacun sous sa clé evenement.etat.<etat>, traduite", () => {
|
||||
assert.deepEqual(ETATS_PLAN, ['brouillon', 'propose', 'retenu', 'bloque']);
|
||||
assert.ok(Object.isFrozen(ETATS_PLAN));
|
||||
assert.ok(ETATS_PLAN.length > 0);
|
||||
for (const nom of ETATS_PLAN) {
|
||||
assert.equal(cleEtat(nom), `evenement.etat.${nom}`);
|
||||
assert.notEqual(t(cleEtat(nom)).trim(), '');
|
||||
}
|
||||
assert.equal(new Set(ETATS_PLAN.map((nom) => t(cleEtat(nom)))).size, 4);
|
||||
});
|
||||
|
||||
test('un état inconnu lève RangeError', () => {
|
||||
assert.throws(() => cleEtat('archive'), RangeError);
|
||||
assert.throws(() => cleEtat('toString'), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repriseOfferte (§ 8.8)', () => {
|
||||
const lointain = { seance: 'lointaine-1', depuis: '2031-05-17T09:00:00-04:00', vivant: null };
|
||||
|
||||
test('en lecture, un verrou refusé dont la vie ne se prouve pas : le verrou, à reprendre', () => {
|
||||
assert.deepEqual(repriseOfferte(etat({ verrouAutre: lointain })), lointain);
|
||||
const illisible = { seance: null, depuis: null, vivant: null };
|
||||
assert.deepEqual(repriseOfferte(etat({ verrouAutre: illisible })), illisible);
|
||||
});
|
||||
|
||||
test('ni sans verrou refusé, ni en écriture, ni sans événement, ni pour un verrou vivant ou mort', () => {
|
||||
assert.equal(repriseOfferte(etat()), null);
|
||||
assert.equal(repriseOfferte(etat({ mode: 'ecriture', verrouAutre: lointain })), null);
|
||||
assert.equal(repriseOfferte(etat({ plan: null, verrouAutre: lointain })), null);
|
||||
assert.equal(repriseOfferte(etat({ verrouAutre: { ...lointain, vivant: true } })), null);
|
||||
assert.equal(repriseOfferte(etat({ verrouAutre: { ...lointain, vivant: false } })), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avertissementsOrdonnes (§ 8.6, § 8.8, § 8.9, § 9)', () => {
|
||||
// Un avertissement de chaque code du contrat, dans l'ordre où la séance
|
||||
// les rend, avec des détails de forme quelconque : la fonction n'en lit
|
||||
// aucun, hors de ceux qu'elle complète.
|
||||
const CODES = [
|
||||
'RETENU_FAUTIF',
|
||||
'JOURNAL_REPRIS',
|
||||
'JOURNAL_EN_AVANCE_DE',
|
||||
'JOURNAL_INCOMPLET',
|
||||
'JOURNAL_ECARTEES',
|
||||
'JOURNAL_RECREE',
|
||||
'JOURNAL_FORMAT_PLUS_RECENT',
|
||||
'JOURNAL_ILLISIBLE',
|
||||
'JOURNAL_ETRANGER',
|
||||
'PROPOSITIONS_FAUTIVES',
|
||||
'PROPOSITIONS_MANQUANTES',
|
||||
'DERIVE',
|
||||
'INVARIANT_VIOLE',
|
||||
'VERROU_REPRIS',
|
||||
'EVENEMENT_CHANGE',
|
||||
'VERROU_NON_RENDU',
|
||||
'JALON_NON_ECRIT',
|
||||
'SUPPORT_AMOVIBLE',
|
||||
'RESERVATIONS_SUSPENDUES',
|
||||
'IMPORT_EN_RETENU',
|
||||
'PLACES_MANQUANTES',
|
||||
'DERIVE_NOUVELLE',
|
||||
'RESERVATION_NON_HONOREE',
|
||||
'CONFIGURATION_INCOHERENTE',
|
||||
'RETOUR_AUTOMATIQUE',
|
||||
];
|
||||
const REMEDES = new Map([
|
||||
['RETENU_FAUTIF', { geste: 'ouvrirSecours', source: 'precedent' }],
|
||||
['DERIVE', { geste: 'ouvrirSection', section: 'propositions' }],
|
||||
['DERIVE_NOUVELLE', { geste: 'ouvrirSection', section: 'propositions' }],
|
||||
['PLACES_MANQUANTES', { geste: 'ouvrirSection', section: 'tables' }],
|
||||
]);
|
||||
const avertissement = (code, rang) => ({ code, details: { rang, ids: [], retenu: false } });
|
||||
|
||||
test('[] sans avertissement', () => {
|
||||
assert.deepEqual(avertissementsOrdonnes(etat()), []);
|
||||
assert.deepEqual(avertissementsOrdonnes(etat({ plan: null })), []);
|
||||
});
|
||||
|
||||
test("l'ordre de la séance, codes et détails gardés, chacun avec son remède, null hors des quatre", () => {
|
||||
const recus = CODES.map(avertissement);
|
||||
const rendus = avertissementsOrdonnes(etat({ avertissements: recus }));
|
||||
assert.equal(rendus.length, CODES.length);
|
||||
assert.ok(rendus.length > 0);
|
||||
rendus.forEach((rendu, rang) => {
|
||||
assert.equal(rendu.code, CODES[rang]);
|
||||
assert.deepEqual(rendu.details, recus[rang].details);
|
||||
assert.deepEqual(rendu.remede, REMEDES.get(rendu.code) ?? null, rendu.code);
|
||||
});
|
||||
});
|
||||
|
||||
test("l'ordre est celui reçu, jamais un ordre propre : la liste renversée revient renversée", () => {
|
||||
const renverses = [...CODES].reverse().map(avertissement);
|
||||
assert.deepEqual(
|
||||
avertissementsOrdonnes(etat({ avertissements: renverses })).map(({ code }) => code),
|
||||
[...CODES].reverse(),
|
||||
);
|
||||
});
|
||||
|
||||
test('PROPOSITIONS_FAUTIVES porte les fautives de la séance, et lui seul', () => {
|
||||
const fautives = [{ rang: 1, id: 4, fautes: [{ code: 'LONGUEUR', chemin: 'tours' }] }];
|
||||
const rendus = avertissementsOrdonnes(
|
||||
etat({ fautives, avertissements: [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [4] } }, avertissement('DERIVE_NOUVELLE', 0)] }),
|
||||
);
|
||||
assert.deepEqual(rendus[0].fautives, fautives);
|
||||
assert.equal(Object.hasOwn(rendus[1], 'fautives'), false);
|
||||
});
|
||||
|
||||
test("DERIVE porte ce que l'examen dit de chaque placement nommé : le retenu d'abord, puis chaque proposition de ids", () => {
|
||||
const raisonRetenu = { code: 'PARTICIPANT_EXCLU', participant: 2 };
|
||||
const raisonCinq = { code: 'TOURS_CHANGES', avant: 3, maintenant: 2 };
|
||||
const examen = {
|
||||
derives: [
|
||||
{ id: 5, raisons: [raisonCinq] },
|
||||
{ id: 9, raisons: [raisonCinq] },
|
||||
],
|
||||
retenu: { fautes: [], raisons: [raisonRetenu] },
|
||||
};
|
||||
const [derive] = avertissementsOrdonnes(
|
||||
etat({ examen, avertissements: [{ code: 'DERIVE', details: { ids: [5], retenu: true } }] }),
|
||||
);
|
||||
assert.deepEqual(derive.derives, [
|
||||
{ id: 'retenu', raisons: [raisonRetenu] },
|
||||
{ id: 5, raisons: [raisonCinq] },
|
||||
]);
|
||||
const [seule] = avertissementsOrdonnes(etat({ examen, avertissements: [{ code: 'DERIVE', details: { ids: [9], retenu: false } }] }));
|
||||
assert.deepEqual(seule.derives, [{ id: 9, raisons: [raisonCinq] }]);
|
||||
const [sansExamen] = avertissementsOrdonnes(etat({ avertissements: [{ code: 'DERIVE', details: { ids: [9], retenu: false } }] }));
|
||||
assert.deepEqual(sansExamen.derives, []);
|
||||
});
|
||||
|
||||
test('chaque remède rendu se traduit : sa clé existe', () => {
|
||||
const rendus = avertissementsOrdonnes(etat({ avertissements: CODES.map(avertissement) }));
|
||||
const avecRemede = rendus.filter(({ remede }) => remede !== null);
|
||||
assert.equal(avecRemede.length, 4);
|
||||
for (const { remede } of avecRemede) {
|
||||
const cle = remede.geste === 'ouvrirSecours' ? `communs.remede.ouvrirSecours.${remede.source}` : `communs.remede.ouvrirSection.${remede.section}`;
|
||||
assert.notEqual(t(cle).trim(), '');
|
||||
}
|
||||
});
|
||||
});
|
||||
40
src/interface/evenement/sections.js
Normal file
40
src/interface/evenement/sections.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les sections de l'événement ouvert. Aucune n'en exige une autre : la
|
||||
// navigation les offre toutes, dans l'ordre du parcours de l'opérateur
|
||||
// (§ 2.1), et la séance refuse elle-même ce qui ne peut pas se faire.
|
||||
|
||||
// Chaque section et la clé de son titre, qui la nomme aussi dans la
|
||||
// navigation, dans l'ordre du § 2.1.
|
||||
const CLES = new Map([
|
||||
['import', 'coquille.section.import'],
|
||||
['participants', 'coquille.section.participants'],
|
||||
['tables', 'coquille.section.tables'],
|
||||
['plan', 'coquille.section.plan'],
|
||||
['diagnostic', 'coquille.section.diagnostic'],
|
||||
['generation', 'coquille.section.generation'],
|
||||
['propositions', 'coquille.section.propositions'],
|
||||
]);
|
||||
|
||||
/** Les sections de l'itération, dans l'ordre du § 2.1 : import,
|
||||
* participants, tables, plan, diagnostic, generation, propositions. Aucune
|
||||
* n'en exige une autre. Le titre et le nom de navigation de chacune sont la
|
||||
* clé coquille.section.<nom> de coquille.js. */
|
||||
export const SECTIONS = Object.freeze([...CLES.keys()]);
|
||||
|
||||
/** La section qu'un événement montre à son ouverture. */
|
||||
export const SECTION_OUVERTURE = 'plan';
|
||||
|
||||
/**
|
||||
* La clé du titre d'une section, coquille.section.<nom>.
|
||||
*
|
||||
* @param {string} nom
|
||||
* @returns {string}
|
||||
* @throws {RangeError} hors de SECTIONS
|
||||
*/
|
||||
export function cleDeSection(nom) {
|
||||
const cle = CLES.get(nom);
|
||||
if (cle === undefined) throw new RangeError(`section inconnue : ${JSON.stringify(nom)}`);
|
||||
return cle;
|
||||
}
|
||||
40
src/interface/evenement/sections.test.js
Normal file
40
src/interface/evenement/sections.test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les sections de l'événement ouvert (§ 2.1) : sept noms distincts, dans
|
||||
// l'ordre du parcours de l'opérateur, chacun nommé par sa clé
|
||||
// coquille.section.<nom> de la table de la coquille ; la section d'ouverture
|
||||
// en est une.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLE as COQUILLE } from '../libelles/coquille.js';
|
||||
import { SECTION_OUVERTURE, SECTIONS, cleDeSection } from './sections.js';
|
||||
|
||||
describe('sections de l’événement (§ 2.1)', () => {
|
||||
test('sept noms distincts, dans l’ordre du parcours, figés', () => {
|
||||
assert.deepEqual(SECTIONS, ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']);
|
||||
assert.equal(new Set(SECTIONS).size, 7);
|
||||
assert.ok(Object.isFrozen(SECTIONS));
|
||||
});
|
||||
|
||||
test('chaque section a sa clé coquille.section.<nom>, une chaîne non vide, que rend cleDeSection', () => {
|
||||
assert.ok(SECTIONS.length > 0);
|
||||
for (const nom of SECTIONS) {
|
||||
assert.equal(cleDeSection(nom), `coquille.section.${nom}`);
|
||||
const texte = COQUILLE.get(cleDeSection(nom));
|
||||
assert.equal(typeof texte, 'string', nom);
|
||||
assert.notEqual(texte.trim(), '', nom);
|
||||
}
|
||||
});
|
||||
|
||||
test('la section d’ouverture est le plan, l’une des sections', () => {
|
||||
assert.equal(SECTION_OUVERTURE, 'plan');
|
||||
assert.ok(SECTIONS.includes(SECTION_OUVERTURE));
|
||||
});
|
||||
|
||||
test('cleDeSection lève RangeError hors de SECTIONS', () => {
|
||||
for (const faute of ['accueil', '', 'Plan', 'constructor', undefined]) {
|
||||
assert.throws(() => cleDeSection(faute), RangeError, String(faute));
|
||||
}
|
||||
});
|
||||
});
|
||||
61
src/interface/generation/Avancement.svelte
Normal file
61
src/interface/generation/Avancement.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// L'avancement d'une génération en cours (§ 5.10) : les mouvements évalués
|
||||
// sur le total, en mots et en barre — une barre de progression accessible,
|
||||
// dont aria-valuenow porte les mouvements évalués —, et « Annuler la
|
||||
// génération », qui appelle annuler. fait et total viennent du contrôleur
|
||||
// de génération ; total est le produit du nombre de propositions et du
|
||||
// compte d'arrêt, jamais nul pendant un calcul.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
|
||||
let { fait, total, annuler } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
|
||||
const largeur = $derived(total > 0 ? `${(fait / total) * 100}%` : '0%');
|
||||
</script>
|
||||
|
||||
<div class="avancement" data-avancement>
|
||||
<p>{t('generation.avancement.texte', { fait, total })}</p>
|
||||
<div
|
||||
class="barre"
|
||||
role="progressbar"
|
||||
aria-label={t('generation.avancement.barre')}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={fait}
|
||||
>
|
||||
<div class="plein" style:width={largeur}></div>
|
||||
</div>
|
||||
<Commande cle="generation.commande.annuler" nom="annuler" executer={annuler} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.avancement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.barre {
|
||||
box-sizing: border-box;
|
||||
width: min(30rem, 100%);
|
||||
height: var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
}
|
||||
|
||||
.plein {
|
||||
height: 100%;
|
||||
background-color: var(--couleur-page-texte);
|
||||
}
|
||||
</style>
|
||||
636
src/interface/generation/Generation.navigateur.test.js
Normal file
636
src/interface/generation/Generation.navigateur.test.js
Normal file
|
|
@ -0,0 +1,636 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La section de la génération dans un vrai moteur de rendu (§ 5.7, § 5.9,
|
||||
// § 5.10, § 2.1 étape 9 et « ce qui doit avoir eu lieu », § 15.4, § 8.4,
|
||||
// § 9) : ses réglages, leur libellé et leur unité ; la graine, saisie de la
|
||||
// commande ; le travailleur, qui rend ce que rend rechercher ; l'application
|
||||
// vivante pendant le calcul ; l'annulation ; les refus posés à côté de
|
||||
// « Générer » avec leur remède ; la durée observée ; la génération longue,
|
||||
// annoncée en place.
|
||||
//
|
||||
// Chaque épreuve monte App par monterApp, sur le système de fichiers
|
||||
// simulé, et nomme son exécuteur : le travailleur — inclus en ligne comme
|
||||
// src/main.js l'inclut — pour un calcul hors du fil ; un exécuteur que
|
||||
// l'épreuve résout à la main quand l'horloge pilotée doit avancer pendant
|
||||
// le calcul ; le direct ailleurs, compté. L'épreuve lit le rendu par les
|
||||
// attributs data-… du contrat, et ce que la séance a écrit : la révision, le
|
||||
// journal, l'état sur le disque, le relevé des écritures. Le texte attendu
|
||||
// vient de la même traduction que celle de l'application ; les noms
|
||||
// d'épreuve sont inventés.
|
||||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { creerExecuteurTravailleur } from '../../application/executeur_travailleur.js';
|
||||
import { ErreurAnnulee, creerExecuteurDirect } from '../../application/generation.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { configurationDepuisCharge } from '../../stockage/document.js';
|
||||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import Travailleur from './travailleur.js?worker&inline';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
// Les champs de la section, dans l'ordre de l'écran.
|
||||
const CHAMPS = ['graine', 'generation.nombre', 'generation.arret', 'generation.historique'];
|
||||
|
||||
// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs
|
||||
// n'a rien reçu pendant l'épreuve.
|
||||
let montes = [];
|
||||
async function monter(options) {
|
||||
const m = await monterApp(options);
|
||||
montes.push(m);
|
||||
return m;
|
||||
}
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
afterEach(() => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// --- Les exécuteurs ---------------------------------------------------------------
|
||||
|
||||
// L'exécuteur du travailleur, celui de src/main.js ; travailleurs compte les
|
||||
// travailleurs créés.
|
||||
function executeurTravailleur() {
|
||||
const suivi = { travailleurs: 0 };
|
||||
const executeur = creerExecuteurTravailleur(() => {
|
||||
suivi.travailleurs += 1;
|
||||
return new Travailleur();
|
||||
});
|
||||
return { executeur, suivi };
|
||||
}
|
||||
|
||||
// Un exécuteur dont chaque lancer est relevé : configuration, reglages, et
|
||||
// ce que rend son résultat. lancements en garde la liste.
|
||||
function releve(base) {
|
||||
const lancements = [];
|
||||
const executeur = {
|
||||
lancer(configuration, reglages) {
|
||||
const execution = base.lancer(configuration, reglages);
|
||||
const lancement = { configuration, reglages, propositions: null };
|
||||
lancements.push(lancement);
|
||||
execution.resultat.then(
|
||||
(propositions) => {
|
||||
lancement.propositions = propositions;
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return execution;
|
||||
},
|
||||
};
|
||||
return { executeur, lancements };
|
||||
}
|
||||
|
||||
// Un exécuteur que l'épreuve résout à la main : chaque lancer est relevé,
|
||||
// avec resoudre(propositions), avancer(fait, total) et annulee.
|
||||
function executeurAMain() {
|
||||
const executions = [];
|
||||
const executeur = {
|
||||
lancer(configuration, reglages) {
|
||||
const abonnes = new Set();
|
||||
let resoudre;
|
||||
let rejeter;
|
||||
const resultat = new Promise((ok, ko) => {
|
||||
resoudre = ok;
|
||||
rejeter = ko;
|
||||
});
|
||||
const execution = {
|
||||
configuration,
|
||||
reglages,
|
||||
resoudre,
|
||||
annulee: false,
|
||||
avancer: (fait, total) => {
|
||||
for (const fn of [...abonnes]) fn(fait, total);
|
||||
},
|
||||
};
|
||||
executions.push(execution);
|
||||
return {
|
||||
surAvancement(fn) {
|
||||
abonnes.add(fn);
|
||||
return () => abonnes.delete(fn);
|
||||
},
|
||||
resultat,
|
||||
annuler() {
|
||||
execution.annulee = true;
|
||||
rejeter(new ErreurAnnulee());
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return { executeur, executions };
|
||||
}
|
||||
|
||||
// --- Les lectures du rendu ----------------------------------------------------
|
||||
|
||||
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
|
||||
const section = (m) => dans(m, '[data-section="generation"]');
|
||||
const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
|
||||
const etiquette = (m, cle) => section(m).querySelector(`label[for="${champ(m, cle).id}"]`);
|
||||
const generer = (m) => section(m).querySelector('[data-commande="generer"]');
|
||||
const refusPres = (element, code) => element.parentElement.querySelector(`[data-refus="${code}"]`);
|
||||
const fenetre = () => document.querySelector('dialog[open]');
|
||||
const duree = (m) => section(m).querySelector('[data-duree]');
|
||||
const question = (m) => section(m).querySelector('[data-question="longue"]');
|
||||
const barre = (m) => section(m)?.querySelector('[data-avancement] [role="progressbar"]') ?? null;
|
||||
|
||||
const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
|
||||
const versSection = (m, cible) => dans(m, `[data-commande="section"][data-cible="${cible}"]`);
|
||||
|
||||
// La signature de ce que le plan montre : la tête de chaque ligne des
|
||||
// listes — son rang et ce qui l'occupe, sans le nom, que la largeur mesurée
|
||||
// abrège —, puis les marques de chaque chaise.
|
||||
const montre = (m) => ({
|
||||
listes: tous(m, '[data-liste]').map((liste) =>
|
||||
[...liste.querySelectorAll('[data-partie="tete"]')].map((tete) => tete.textContent),
|
||||
),
|
||||
marques: tous(m, '[data-siege]').map((chaise) => [
|
||||
chaise.getAttribute('data-siege'),
|
||||
[...chaise.querySelectorAll('[data-marque]')].map((marque) => marque.getAttribute('data-marque')),
|
||||
]),
|
||||
});
|
||||
|
||||
// Choisit le tour r dans le sélecteur de la barre de vue du plan.
|
||||
function choisirTour(m, r) {
|
||||
const select = dans(m, '[data-section="plan"] [data-champ="tour"]');
|
||||
expect(select, 'sélecteur de tour').not.toBeNull();
|
||||
select.value = String(r);
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Cadre le plan sur ses tables, puis le rapproche, un zoom après l'autre,
|
||||
// jusqu'à ce que les listes de noms et les chaises paraissent : elles ne se
|
||||
// rendent qu'au-delà d'un zoom. Partie du même cadrage, la vue atteinte est
|
||||
// la même à chaque appel.
|
||||
async function cadrerPourLesListes(m) {
|
||||
const commande = (nom) => dans(m, `[data-section="plan"] [data-commande="${nom}"]`);
|
||||
commande('ajuster').click();
|
||||
flushSync();
|
||||
await images(2);
|
||||
for (let cran = 0; tous(m, '[data-liste]').length === 0 || tous(m, '[data-siege]').length === 0; cran += 1) {
|
||||
expect(cran, 'zooms avant les listes et les chaises').toBeLessThan(30);
|
||||
commande('zoomer').click();
|
||||
flushSync();
|
||||
await images(2);
|
||||
}
|
||||
}
|
||||
|
||||
const charge = (m) => m.seance.etat().charge;
|
||||
const revision = (m) => m.seance.etat().evenement.revision;
|
||||
const controleur = (m) => m.app.generation();
|
||||
const baseDe = (m) => m.seance.etat().evenement.base;
|
||||
const etatSurDisque = (m) => m.fs.contenu('documents', `${baseDe(m)}${SUFFIXES.etat}`);
|
||||
const journalDe = (m) => lireJournal(m.fs.contenu('documents', `${baseDe(m)}${SUFFIXES.journal}`));
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour, image après image ;
|
||||
// images borne l'attente.
|
||||
async function attendre(assertion, images = 1200) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= images) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Laisse passer n images.
|
||||
async function images(n) {
|
||||
for (let i = 0; i < n; i += 1) await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
|
||||
// Attend que la file de la séance ait passé ce qui s'y trouve, par une
|
||||
// lecture admise dans tout mode qui n'écrit rien, puis le rendu.
|
||||
async function passer(m) {
|
||||
await m.seance.formeNommee(null).catch(() => undefined);
|
||||
await images(1);
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Un clic réel au centre de l'élément, puis la file de la séance passée.
|
||||
async function cliquer(m, element) {
|
||||
expect(element, 'élément à cliquer').not.toBeNull();
|
||||
element.scrollIntoView({ block: 'center' });
|
||||
const { left, top, width, height } = element.getBoundingClientRect();
|
||||
await pointer('presser', left + width / 2, top + height / 2);
|
||||
await pointer('relacher', left + width / 2, top + height / 2);
|
||||
await passer(m);
|
||||
}
|
||||
|
||||
// Saisit texte dans le champ, la valeur courante remplacée, puis le quitte
|
||||
// par Tab, ou le valide par Entrée.
|
||||
async function saisir(m, entree, texte, { fin = 'Tab' } = {}) {
|
||||
await cliquer(m, entree);
|
||||
entree.select();
|
||||
for (const caractere of texte) await touche(caractere);
|
||||
if (fin !== null) await touche(fin);
|
||||
await passer(m);
|
||||
}
|
||||
|
||||
// Vrai tant que le contrôleur publie une génération en cours.
|
||||
function enCours(m) {
|
||||
let lu;
|
||||
controleur(m).subscribe((etat) => {
|
||||
lu = etat;
|
||||
})();
|
||||
return lu.enCours;
|
||||
}
|
||||
|
||||
// --- Les événements d'épreuve -----------------------------------------------------
|
||||
|
||||
// Un montage sur l'exécuteur donné, où la démonstration cle est chargée,
|
||||
// en écriture sauf ecriture faux, la section de la génération ouverte.
|
||||
async function demonstration(cle, { executeur, ecriture = true } = {}) {
|
||||
const m = await monter({ executeur });
|
||||
await m.facade.chargerDemonstration(cle);
|
||||
if (ecriture) await m.seance.passerEnEcriture();
|
||||
m.ui.allerA('generation');
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
// Les réglages de génération de la charge changés par la séance.
|
||||
async function regler(m, reglages) {
|
||||
for (const [cle, valeur] of Object.entries(reglages)) {
|
||||
await m.seance.executer('modifierReglage', { cle: `generation.${cle}`, valeur });
|
||||
}
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// --- Les épreuves -----------------------------------------------------------------
|
||||
|
||||
describe('les réglages (§ 5.7, § 5.10)', () => {
|
||||
test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
|
||||
const entrees = [...section(m).querySelectorAll('input')];
|
||||
expect(entrees.length).toBe(4);
|
||||
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
|
||||
for (const cle of CHAMPS) {
|
||||
const cleLibelle = `generation.reglage.${cle.replace('generation.', '')}`;
|
||||
expect(etiquette(m, cle).textContent).toBe(t(cleLibelle));
|
||||
}
|
||||
expect(t('generation.reglage.arret')).toContain('mouvements évalués par proposition');
|
||||
expect(t('generation.reglage.nombre')).toContain('propositions');
|
||||
expect(t('generation.reglage.historique')).toContain('mouvements');
|
||||
expect(t('generation.reglage.graine')).toContain('entier');
|
||||
expect(champ(m, 'generation.historique').value).toBe('1000');
|
||||
expect(champ(m, 'generation.nombre').value).toBe('5');
|
||||
expect(champ(m, 'generation.arret').value).toBe('200000');
|
||||
for (const entree of entrees) {
|
||||
expect(etiquette(m, entree.dataset.champ).textContent).not.toMatch(/durée|seconde|minute/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('exécuteur direct : changer chaque réglage et quitter le champ — une entrée, ce réglage de la charge changé, les deux autres non', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
|
||||
// Chaque réglage de la charge, et une valeur neuve, distincte de celle
|
||||
// de la démonstration.
|
||||
const saisies = { nombre: 3, arret: 40000, historique: 500 };
|
||||
const noms = Object.keys(saisies);
|
||||
expect(noms.length).toBe(3);
|
||||
for (const nom of noms) {
|
||||
const avant = { ...charge(m).reglages.generation };
|
||||
expect(avant[nom], nom).not.toBe(saisies[nom]);
|
||||
const r0 = revision(m);
|
||||
const entrees = journalDe(m).entrees.length;
|
||||
await saisir(m, champ(m, `generation.${nom}`), String(saisies[nom]));
|
||||
expect(revision(m), nom).toBe(r0 + 1);
|
||||
expect(journalDe(m).entrees.length, nom).toBe(entrees + 1);
|
||||
expect(charge(m).reglages.generation, nom).toEqual({ ...avant, [nom]: saisies[nom] });
|
||||
expect(champ(m, `generation.${nom}`).value, nom).toBe(String(saisies[nom]));
|
||||
}
|
||||
});
|
||||
|
||||
test('exécuteur direct : en lecture, la frappe dans un réglage est refusée avec « Modifier » ; la graine se saisit sans rien écrire', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect(), ecriture: false });
|
||||
const ecritures = m.trace.length;
|
||||
const arret = champ(m, 'generation.arret');
|
||||
await saisir(m, arret, '9', { fin: null });
|
||||
expect(arret.value).toBe('200000');
|
||||
const refus = refusPres(arret, 'MODE_LECTURE');
|
||||
expect(refus).not.toBeNull();
|
||||
expect(refus.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||||
await touche('Escape');
|
||||
await saisir(m, champ(m, 'graine'), '77');
|
||||
expect(champ(m, 'graine').value).toBe('77');
|
||||
expect(champ(m, 'graine').parentElement.querySelector('[data-refus]')).toBeNull();
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(fenetre()).toBeNull();
|
||||
});
|
||||
|
||||
test('exécuteur direct, relevé : la graine vaut 1 à l’ouverture, 2 après une génération lancée à 1', async () => {
|
||||
const { executeur, lancements } = releve(creerExecuteurDirect());
|
||||
const m = await demonstration('petite', { executeur });
|
||||
await regler(m, { nombre: 2, arret: 2000 });
|
||||
expect(champ(m, 'graine').value).toBe('1');
|
||||
await cliquer(m, generer(m));
|
||||
await attendre(() => expect(charge(m).propositions.length).toBe(2));
|
||||
expect(lancements.map(({ reglages }) => reglages.graine)).toEqual([1]);
|
||||
await attendre(() => expect(champ(m, 'graine').value).toBe('2'));
|
||||
});
|
||||
|
||||
test('exécuteur direct, relevé : graine -1, 1,5 ou 2^32 — refus sur le champ, rien n’est lancé ni écrit', async () => {
|
||||
const { executeur, lancements } = releve(creerExecuteurDirect());
|
||||
const m = await demonstration('petite', { executeur });
|
||||
const ecritures = m.trace.length;
|
||||
const saisies = ['-1', '1,5', String(2 ** 32)];
|
||||
expect(saisies.length).toBe(3);
|
||||
for (const saisie of saisies) {
|
||||
const attendu = controleur(m).valider({ ...controleur(m).reglagesProposes(), graine: saisie }).refus;
|
||||
expect(attendu.map(({ champ: nom }) => nom)).toEqual(['graine']);
|
||||
await saisir(m, champ(m, 'graine'), saisie);
|
||||
await cliquer(m, generer(m));
|
||||
const graine = champ(m, 'graine');
|
||||
const refus = refusPres(graine, attendu[0].code);
|
||||
expect(refus, saisie).not.toBeNull();
|
||||
expect(refus.querySelector('p').textContent).toBe(t(attendu[0].code, attendu[0].details));
|
||||
expect(graine.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(generer(m).parentElement.querySelector('[data-refus]')).toBeNull();
|
||||
}
|
||||
expect(lancements).toEqual([]);
|
||||
expect(charge(m).propositions).toEqual([]);
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(fenetre()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => {
|
||||
test('exécuteur du travailleur : petite démonstration, graine écrite — plans, graines dérivées et identifiants égaux à rechercher appelé ici', async () => {
|
||||
const { executeur: travailleur, suivi } = executeurTravailleur();
|
||||
const { executeur, lancements } = releve(travailleur);
|
||||
const m = await demonstration('petite', { executeur });
|
||||
await regler(m, { nombre: 3, arret: 20000 });
|
||||
const configuration = configurationDepuisCharge(charge(m));
|
||||
await saisir(m, champ(m, 'graine'), '4242');
|
||||
await cliquer(m, generer(m));
|
||||
await attendre(() => expect(lancements[0]?.propositions ?? null).not.toBeNull());
|
||||
expect(suivi.travailleurs).toBe(1);
|
||||
const reglages = { graine: 4242, nombre: 3, arret: 20000, historique: 1000 };
|
||||
expect(lancements[0].reglages).toEqual(reglages);
|
||||
const attendues = rechercher(configuration, reglages);
|
||||
expect(attendues.length).toBe(3);
|
||||
expect(lancements[0].propositions).toEqual(attendues);
|
||||
await attendre(() => expect(charge(m).propositions.length).toBe(3));
|
||||
expect(charge(m).propositions.map(({ graine }) => graine)).toEqual(attendues.map(({ graine }) => graine));
|
||||
}, 60_000);
|
||||
|
||||
test('exécuteur du travailleur : grande démonstration, compte élevé — pendant le calcul, la navigation et le changement du tour affiché mettent le DOM à jour ; l’avancement croît, aria-busy suit le calcul', async () => {
|
||||
const { executeur } = executeurTravailleur();
|
||||
const m = await demonstration('grande', { executeur });
|
||||
await regler(m, { nombre: 4, arret: 400000 });
|
||||
// Une réservation du seul tour 1, à un siège : une marque et une ligne
|
||||
// qui ne paraissent qu'à ce tour. Le plan de chaque tour est relevé
|
||||
// avant le calcul.
|
||||
const { participants, reservations } = charge(m);
|
||||
const libre = participants.find(({ id }) => !reservations.some(({ participant }) => participant === id));
|
||||
await m.seance.executer('reserver', { participant: libre.id, table: 1, siege: 2, portee: 'tour', tour: 1 });
|
||||
await cliquer(m, versSection(m, 'plan'));
|
||||
await cadrerPourLesListes(m);
|
||||
choisirTour(m, 1);
|
||||
await images(2);
|
||||
const tour1 = montre(m);
|
||||
choisirTour(m, 2);
|
||||
await images(2);
|
||||
const tour2 = montre(m);
|
||||
expect(tour1.listes.length).toBeGreaterThan(0);
|
||||
expect(tour1.marques.length).toBeGreaterThan(0);
|
||||
expect(tour2.listes).not.toEqual(tour1.listes);
|
||||
choisirTour(m, 1);
|
||||
await cliquer(m, versSection(m, 'generation'));
|
||||
|
||||
expect(section(m).getAttribute('aria-busy')).toBe('false');
|
||||
await cliquer(m, generer(m));
|
||||
await attendre(() => expect(Number(barre(m)?.getAttribute('aria-valuenow') ?? 0)).toBeGreaterThan(0));
|
||||
expect(section(m).getAttribute('aria-busy')).toBe('true');
|
||||
const valeurs = [Number(barre(m).getAttribute('aria-valuenow'))];
|
||||
|
||||
// Un geste de navigation pendant le calcul : le DOM change avant la fin.
|
||||
await cliquer(m, versSection(m, 'participants'));
|
||||
expect(dans(m, '[data-section="participants"]')).not.toBeNull();
|
||||
expect(section(m)).toBeNull();
|
||||
expect(enCours(m)).toBe(true);
|
||||
|
||||
// Le plan pendant le calcul : il montre le tour 1, puis, le tour
|
||||
// changé, les listes et les marques du tour 2, le calcul toujours en
|
||||
// cours.
|
||||
await cliquer(m, versSection(m, 'plan'));
|
||||
await cadrerPourLesListes(m);
|
||||
await attendre(() => expect(montre(m)).toEqual(tour1));
|
||||
choisirTour(m, 2);
|
||||
await attendre(() => expect(montre(m)).toEqual(tour2));
|
||||
expect(m.ui.etat().tour).toBe(2);
|
||||
expect(enCours(m)).toBe(true);
|
||||
|
||||
await cliquer(m, versSection(m, 'generation'));
|
||||
expect(section(m).getAttribute('aria-busy')).toBe('true');
|
||||
|
||||
while (enCours(m)) {
|
||||
const lue = barre(m)?.getAttribute('aria-valuenow');
|
||||
if (lue !== undefined && Number(lue) !== valeurs.at(-1)) valeurs.push(Number(lue));
|
||||
await images(1);
|
||||
}
|
||||
expect(valeurs.length).toBeGreaterThanOrEqual(2);
|
||||
for (let i = 1; i < valeurs.length; i += 1) expect(valeurs[i]).toBeGreaterThan(valeurs[i - 1]);
|
||||
await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
|
||||
expect(barre(m)).toBeNull();
|
||||
await attendre(() => expect(charge(m).propositions.length).toBeGreaterThan(0));
|
||||
}, 120_000);
|
||||
|
||||
test('exécuteur du travailleur : annuler est immédiat ; plus aucun avancement ; l’état prêt revient ; propositions, journal et état sur le disque inchangés', async () => {
|
||||
const { executeur } = executeurTravailleur();
|
||||
const m = await demonstration('grande', { executeur });
|
||||
await regler(m, { nombre: 4, arret: 400000 });
|
||||
const avant = { propositions: charge(m).propositions, journal: journalDe(m), etat: etatSurDisque(m) };
|
||||
await cliquer(m, generer(m));
|
||||
await attendre(() => expect(Number(barre(m)?.getAttribute('aria-valuenow') ?? 0)).toBeGreaterThan(0));
|
||||
const recus = [];
|
||||
const desabonner = controleur(m).subscribe(({ fait }) => recus.push(fait));
|
||||
await cliquer(m, section(m).querySelector('[data-avancement] [data-commande="annuler"]'));
|
||||
expect(enCours(m)).toBe(false);
|
||||
expect(section(m).getAttribute('aria-busy')).toBe('false');
|
||||
expect(barre(m)).toBeNull();
|
||||
expect(generer(m)).not.toBeNull();
|
||||
const apresAnnulation = recus.length;
|
||||
await images(30);
|
||||
expect(recus.length).toBe(apresAnnulation);
|
||||
desabonner();
|
||||
expect(charge(m).propositions).toEqual(avant.propositions);
|
||||
expect(journalDe(m)).toEqual(avant.journal);
|
||||
expect(etatSurDisque(m)).toBe(avant.etat);
|
||||
expect(fenetre()).toBeNull();
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('les refus à la commande (§ 5.9, § 15.4, § 9)', () => {
|
||||
test('exécuteur direct, relevé : 13 participants, 4 tables de 3 — PLACES_MANQUANTES {placesManquantes: 1} près de « Générer », remède vers les tables ; rien de lancé ni d’écrit', async () => {
|
||||
const { executeur, lancements } = releve(creerExecuteurDirect());
|
||||
const m = await demonstration('petite', { executeur });
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } });
|
||||
flushSync();
|
||||
expect(charge(m).participants.length).toBe(13);
|
||||
expect(charge(m).tables.length).toBe(4);
|
||||
const r0 = revision(m);
|
||||
const ecritures = m.trace.length;
|
||||
await cliquer(m, generer(m));
|
||||
const refus = refusPres(generer(m), 'PLACES_MANQUANTES');
|
||||
expect(refus).not.toBeNull();
|
||||
const message = refus.querySelector('p').textContent;
|
||||
expect(message).toBe(t('PLACES_MANQUANTES', { placesManquantes: 1 }));
|
||||
expect(message).toContain('1 place');
|
||||
const remede = refus.querySelector('[data-commande="ouvrirSection"]');
|
||||
expect(remede.textContent).toBe(t('communs.remede.ouvrirSection.tables'));
|
||||
expect(fenetre()).toBeNull();
|
||||
await cliquer(m, remede);
|
||||
expect(m.ui.etat().section).toBe('tables');
|
||||
expect(dans(m, '[data-section="tables"]')).not.toBeNull();
|
||||
expect(lancements).toEqual([]);
|
||||
expect(charge(m).propositions).toEqual([]);
|
||||
expect(revision(m)).toBe(r0);
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
});
|
||||
|
||||
test('exécuteur direct, relevé : un événement neuf en écriture — AUCUN_PARTICIPANT puis, des participants sans table, AUCUNE_TABLE ; chaque remède ouvre sa section', async () => {
|
||||
const { executeur, lancements } = releve(creerExecuteurDirect());
|
||||
const m = await monter({ executeur });
|
||||
await m.seance.creer({ nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 });
|
||||
await m.seance.passerEnEcriture();
|
||||
m.ui.allerA('generation');
|
||||
flushSync();
|
||||
let ecritures = m.trace.length;
|
||||
await cliquer(m, generer(m));
|
||||
const sansPersonne = refusPres(generer(m), 'AUCUN_PARTICIPANT');
|
||||
expect(sansPersonne).not.toBeNull();
|
||||
expect(sansPersonne.querySelector('p').textContent).toBe(t('AUCUN_PARTICIPANT', {}));
|
||||
expect(fenetre()).toBeNull();
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
await cliquer(m, sansPersonne.querySelector('[data-commande="ouvrirSection"]'));
|
||||
expect(m.ui.etat().section).toBe('participants');
|
||||
expect(dans(m, '[data-section="participants"]')).not.toBeNull();
|
||||
|
||||
for (const nom of ['Pervenche', 'Lacasse']) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||||
m.ui.allerA('generation');
|
||||
flushSync();
|
||||
ecritures = m.trace.length;
|
||||
await cliquer(m, generer(m));
|
||||
const sansTable = refusPres(generer(m), 'AUCUNE_TABLE');
|
||||
expect(sansTable).not.toBeNull();
|
||||
expect(sansTable.querySelector('p').textContent).toBe(t('AUCUNE_TABLE', {}));
|
||||
expect(fenetre()).toBeNull();
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
await cliquer(m, sansTable.querySelector('[data-commande="ouvrirSection"]'));
|
||||
expect(m.ui.etat().section).toBe('tables');
|
||||
expect(dans(m, '[data-section="tables"]')).not.toBeNull();
|
||||
expect(lancements).toEqual([]);
|
||||
});
|
||||
|
||||
test('exécuteur direct, relevé : en lecture, « Générer » refusé à côté de lui avec « Modifier » ; sur un plan bloqué, avec « Débloquer » ; aucune fenêtre', async () => {
|
||||
const { executeur, lancements } = releve(creerExecuteurDirect());
|
||||
const m = await demonstration('petite', { executeur, ecriture: false });
|
||||
const ecritures = m.trace.length;
|
||||
await cliquer(m, generer(m));
|
||||
const lecture = refusPres(generer(m), 'MODE_LECTURE');
|
||||
expect(lecture).not.toBeNull();
|
||||
expect(lecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||||
expect(fenetre()).toBeNull();
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
|
||||
await m.seance.passerEnEcriture();
|
||||
const configuration = configurationDepuisCharge(charge(m));
|
||||
const propositions = rechercher(configuration, { graine: 7, nombre: 1, arret: 2000 });
|
||||
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
|
||||
await m.seance.executer('retenirProposition', { id: charge(m).propositions[0].id });
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
flushSync();
|
||||
const ecrituresBloque = m.trace.length;
|
||||
await cliquer(m, generer(m));
|
||||
const bloque = refusPres(generer(m), 'LECTURE_SEULE');
|
||||
expect(bloque).not.toBeNull();
|
||||
expect(bloque.querySelector('p').textContent).toBe(t('LECTURE_SEULE', m.seance.etat().refus.details));
|
||||
expect(bloque.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
|
||||
expect(fenetre()).toBeNull();
|
||||
expect(m.trace.length).toBe(ecrituresBloque);
|
||||
expect(lancements).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la durée du calcul (§ 5.10, § 19.4)', () => {
|
||||
test('exécuteur résolu à la main : sans mesure, le panneau le dit sans écrire 0 ; 1 800 ms d’horloge pilotée pour 5 × 200 000 mouvements, durée et réglages affichés', async () => {
|
||||
const { executeur, executions } = executeurAMain();
|
||||
const m = await demonstration('petite', { executeur });
|
||||
expect(duree(m).textContent).toBe(t('generation.duree.inconnue'));
|
||||
expect(duree(m).textContent).not.toMatch(/\b0\b/);
|
||||
await cliquer(m, generer(m));
|
||||
expect(executions.length).toBe(1);
|
||||
expect(executions[0].reglages).toEqual({ graine: 1, nombre: 5, arret: 200000, historique: 1000 });
|
||||
expect(section(m).getAttribute('aria-busy')).toBe('true');
|
||||
m.horloge.avancer(1800);
|
||||
const propositions = rechercher(executions[0].configuration, { graine: 1, nombre: 5, arret: 2000 });
|
||||
executions[0].resoudre(propositions);
|
||||
await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
|
||||
await attendre(() => expect(charge(m).propositions.length).toBe(5));
|
||||
const attendu = t('generation.duree.observee', { dureeMs: 1800, nombre: 5, arret: 200000 });
|
||||
expect(duree(m).textContent).toBe(attendu);
|
||||
expect(attendu).toContain('1,8 s');
|
||||
expect(attendu).toContain('5 \u{00D7} 200\u{202F}000');
|
||||
});
|
||||
|
||||
test('exécuteur résolu à la main : 10^6 mouvements en 2 s ; 6 × 10^8 annoncés en place, « environ vingt minutes à ce réglage » ; renoncer n’écrit rien, confirmer lance ; sous le seuil, sans question', async () => {
|
||||
const { executeur, executions } = executeurAMain();
|
||||
const m = await demonstration('petite', { executeur });
|
||||
await cliquer(m, generer(m));
|
||||
expect(executions.length).toBe(1);
|
||||
m.horloge.avancer(2000);
|
||||
executions[0].resoudre([]);
|
||||
await attendre(() => expect(enCours(m)).toBe(false));
|
||||
expect(controleur(m).derniereGeneration()).toMatchObject({ dureeMs: 2000, mouvements: 1_000_000 });
|
||||
|
||||
await saisir(m, champ(m, 'generation.arret'), '120000000');
|
||||
expect(charge(m).reglages.generation).toMatchObject({ nombre: 5, arret: 120_000_000 });
|
||||
const journal = journalDe(m);
|
||||
const ecritures = m.trace.length;
|
||||
await cliquer(m, generer(m));
|
||||
expect(executions.length).toBe(1);
|
||||
expect(question(m)).not.toBeNull();
|
||||
expect(question(m).textContent).toContain('environ vingt minutes à ce réglage');
|
||||
expect(fenetre()).toBeNull();
|
||||
|
||||
await cliquer(m, question(m).querySelector('[data-commande="renoncerGeneration"]'));
|
||||
expect(question(m)).toBeNull();
|
||||
expect(executions.length).toBe(1);
|
||||
expect(journalDe(m)).toEqual(journal);
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
|
||||
await cliquer(m, generer(m));
|
||||
await cliquer(m, question(m).querySelector('[data-commande="confirmerGeneration"]'));
|
||||
expect(executions.length).toBe(2);
|
||||
expect(executions[1].reglages).toMatchObject({ nombre: 5, arret: 120_000_000 });
|
||||
expect(question(m)).toBeNull();
|
||||
expect(section(m).getAttribute('aria-busy')).toBe('true');
|
||||
await cliquer(m, section(m).querySelector('[data-avancement] [data-commande="annuler"]'));
|
||||
expect(executions[1].annulee).toBe(true);
|
||||
await attendre(() => expect(enCours(m)).toBe(false));
|
||||
|
||||
await regler(m, { arret: 200000 });
|
||||
await cliquer(m, generer(m));
|
||||
expect(question(m)).toBeNull();
|
||||
expect(executions.length).toBe(3);
|
||||
executions[2].resoudre([]);
|
||||
await attendre(() => expect(enCours(m)).toBe(false));
|
||||
});
|
||||
});
|
||||
128
src/interface/generation/Reglages.svelte
Normal file
128
src/interface/generation/Reglages.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Les réglages d'une génération (§ 5.7, § 5.10) : la graine, puis le
|
||||
// nombre de propositions, le compte d'arrêt et l'historique, chacun sous
|
||||
// son libellé, qui porte son unité ; puis la durée observée du dernier
|
||||
// calcul de la séance, et ce qu'elle laisse attendre des réglages courants.
|
||||
// Aucun champ ne prend une durée : la recherche s'arrête sur un compte.
|
||||
//
|
||||
// Les trois réglages de la charge (generation.nombre, generation.arret,
|
||||
// generation.historique) sont des Champ : une saisie se lit par la
|
||||
// validation du contrôleur de génération, la même que celle de
|
||||
// « Générer », et s'écrit à la sortie du champ par modifierReglage — une
|
||||
// entrée par changement ; en lecture, la séance refuse la première frappe.
|
||||
//
|
||||
// La graine n'est pas au fichier : c'est une saisie de la commande, vivante
|
||||
// en lecture, que le parent tient (graine, liée) et contrôle au clic de
|
||||
// « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
|
||||
// appelle saisirGraine, qui le retire.
|
||||
import { getContext } from 'svelte';
|
||||
import Champ from '../primitives/Champ.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
||||
let { charge, mesure, estimation, graine = $bindable(), refusGraine = null, saisirGraine } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const generation = getContext('generation');
|
||||
const uid = $props.id();
|
||||
const idGraine = `${uid}-graine`;
|
||||
const idRefusGraine = `${uid}-graine-refus`;
|
||||
|
||||
// Les réglages de la charge, dans l'ordre de l'écran.
|
||||
const REGLAGES = ['nombre', 'arret', 'historique'];
|
||||
|
||||
// La lecture d'une saisie du réglage nom : la validation du contrôleur sur
|
||||
// les réglages proposés, ce réglage remplacé par la saisie ; son refus, ou
|
||||
// à défaut le premier, sinon la valeur lue.
|
||||
const lecteur = (nom) => (texte) => {
|
||||
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), [nom]: texte });
|
||||
const propre = refus.find(({ champ }) => champ === nom) ?? refus[0];
|
||||
if (propre !== undefined) return { refus: { code: propre.code, details: propre.details } };
|
||||
return { valeur: reglages[nom] };
|
||||
};
|
||||
|
||||
const ecrire = (nom) => (valeur) => seance.executer('modifierReglage', { cle: `generation.${nom}`, valeur });
|
||||
</script>
|
||||
|
||||
<div class="reglages" data-reglages>
|
||||
<div class="champs">
|
||||
<div class="champ">
|
||||
<label for={idGraine}>{t('generation.reglage.graine')}</label>
|
||||
<input
|
||||
id={idGraine}
|
||||
type="text"
|
||||
data-champ="graine"
|
||||
bind:value={graine}
|
||||
aria-invalid={refusGraine === null ? undefined : 'true'}
|
||||
aria-describedby={refusGraine === null ? undefined : idRefusGraine}
|
||||
oninput={() => saisirGraine()}
|
||||
/>
|
||||
{#if refusGraine !== null}
|
||||
<Refus id={idRefusGraine} refus={refusGraine} />
|
||||
{/if}
|
||||
</div>
|
||||
{#each REGLAGES as nom (nom)}
|
||||
<Champ
|
||||
cle={`generation.${nom}`}
|
||||
etiquette={`generation.reglage.${nom}`}
|
||||
valeur={charge.reglages.generation[nom]}
|
||||
lire={lecteur(nom)}
|
||||
executer={ecrire(nom)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if mesure === null}
|
||||
<p data-duree>{t('generation.duree.inconnue')}</p>
|
||||
{:else}
|
||||
<p data-duree>{t('generation.duree.observee', mesure)}</p>
|
||||
<p data-estimation>{t('generation.duree.estimee', { secondes: estimation.secondes })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reglages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
.champs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
input[aria-invalid='true'] {
|
||||
border-color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
173
src/interface/generation/SectionGeneration.svelte
Normal file
173
src/interface/generation/SectionGeneration.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section de la génération (§ 2.1 étape 9, § 5.7, § 5.9, § 5.10,
|
||||
// § 15.4) : les réglages, « Générer », la question d'une génération longue,
|
||||
// l'avancement d'un calcul et l'issue du dernier. Tout passe par le
|
||||
// contrôleur de génération du contexte, qui seul contrôle, lance, mesure et
|
||||
// enregistre ; la section ne refait aucun de ses contrôles.
|
||||
//
|
||||
// « Générer » lit la graine saisie et les réglages de la charge par
|
||||
// controleur.valider : un refus de la graine se pose contre elle, et rien
|
||||
// ne part. Un refus du contrôleur — l'état de la séance, ce qui doit avoir
|
||||
// eu lieu, les places manquantes — se pose à côté de « Générer », avec son
|
||||
// remède ; aucune fenêtre ne s'ouvre. Une génération longue
|
||||
// (CONFIRMATION_REQUISE) pose en place la question qui nomme l'ordre de
|
||||
// grandeur attendu : « Générer quand même » la relance confirmée, « Ne pas
|
||||
// générer » la retire sans rien écrire. La question tombe à toute autre
|
||||
// frappe dans la graine, au clic de « Générer », et dès que la charge
|
||||
// change : elle ne vaut que pour les réglages qu'elle nomme.
|
||||
//
|
||||
// La graine est pré-remplie de controleur.grainePropose() au montage, puis,
|
||||
// à chaque lancement, de la graine suivante. Pendant le calcul, la section
|
||||
// porte aria-busy="true" et montre l'avancement ; « Générer » reste offert,
|
||||
// et le contrôleur refuse un second lancement. Une annulation n'est pas un
|
||||
// refus : l'état prêt revient, et l'issue le dit. Le contrôleur vit plus
|
||||
// longtemps que la section : remontée pendant un calcul, elle en retrouve
|
||||
// l'avancement par subscribe.
|
||||
import { getContext, onDestroy, untrack } from 'svelte';
|
||||
import { ErreurCommande } from '../../application/erreurs.js';
|
||||
import { ErreurAnnulee } from '../../application/generation.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Avancement from './Avancement.svelte';
|
||||
import Reglages from './Reglages.svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const generation = getContext('generation');
|
||||
|
||||
// La graine saisie, et son refus ; la question d'une génération longue,
|
||||
// { estimation } ; l'issue de la dernière génération lancée d'ici,
|
||||
// { ajoutees, identiques }.
|
||||
let graine = $state(String(generation.grainePropose()));
|
||||
let refusGraine = $state.raw(null);
|
||||
let question = $state.raw(null);
|
||||
let issue = $state.raw(null);
|
||||
|
||||
// Un lancement — enCours qui passe à vrai — met dans le champ la graine
|
||||
// suivante et retire la question, le refus de la graine et l'issue d'avant.
|
||||
let enCoursAvant = false;
|
||||
const desabonner = generation.subscribe((etat) => {
|
||||
if (etat.enCours && !enCoursAvant) {
|
||||
graine = String(generation.grainePropose());
|
||||
refusGraine = null;
|
||||
question = null;
|
||||
issue = null;
|
||||
}
|
||||
enCoursAvant = etat.enCours;
|
||||
});
|
||||
onDestroy(desabonner);
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
|
||||
// La question tombe quand la charge change.
|
||||
$effect.pre(() => {
|
||||
void charge;
|
||||
untrack(() => {
|
||||
question = null;
|
||||
});
|
||||
});
|
||||
|
||||
// La mesure du dernier calcul de la séance, relue à chaque publication du
|
||||
// contrôleur, et l'estimation des réglages de la charge qu'elle donne.
|
||||
const mesure = $derived.by(() => {
|
||||
void $generation;
|
||||
return generation.derniereGeneration();
|
||||
});
|
||||
const estimation = $derived(
|
||||
mesure === null || charge === null ? null : generation.estimer(charge.reglages.generation),
|
||||
);
|
||||
|
||||
function saisirGraine() {
|
||||
refusGraine = null;
|
||||
question = null;
|
||||
}
|
||||
|
||||
// Lance une génération sur la graine saisie et les réglages de la charge ;
|
||||
// confirmee : la réponse à la question d'une génération longue.
|
||||
async function lancer(confirmee) {
|
||||
refusGraine = null;
|
||||
issue = null;
|
||||
if (!confirmee) question = null;
|
||||
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine });
|
||||
const deGraine = refus.find(({ champ }) => champ === 'graine');
|
||||
if (deGraine !== undefined) {
|
||||
refusGraine = { code: deGraine.code, details: deGraine.details };
|
||||
question = null;
|
||||
return;
|
||||
}
|
||||
if (reglages === null) throw new ErreurCommande(refus[0].code, refus[0].details);
|
||||
try {
|
||||
const rendu = await generation.generer(reglages, { confirmee });
|
||||
issue = { ajoutees: rendu.ajoutees.length, identiques: rendu.identiques };
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurAnnulee) return;
|
||||
if (erreur?.code === 'CONFIRMATION_REQUISE') {
|
||||
question = { estimation: erreur.details.estimation };
|
||||
return;
|
||||
}
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section data-section="generation" aria-busy={$generation.enCours ? 'true' : 'false'}>
|
||||
<h2>{t('coquille.section.generation')}</h2>
|
||||
{#if charge !== null}
|
||||
<Reglages {charge} {mesure} {estimation} bind:graine {refusGraine} {saisirGraine} />
|
||||
<div class="lancer">
|
||||
<Commande cle="generation.commande.generer" nom="generer" executer={() => lancer(false)} />
|
||||
</div>
|
||||
{#if question !== null}
|
||||
<div class="question" data-question="longue" role="status">
|
||||
<p>{t('generation.longue.question', question)}</p>
|
||||
<div class="issues">
|
||||
<Commande cle="generation.longue.confirmer" nom="confirmerGeneration" executer={() => lancer(true)} />
|
||||
<Commande cle="generation.longue.renoncer" nom="renoncerGeneration" executer={() => (question = null)} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $generation.enCours}
|
||||
<Avancement fait={$generation.fait} total={$generation.total} annuler={() => generation.annuler()} />
|
||||
{:else if issue !== null}
|
||||
<p data-issue role="status">{t('generation.issue.ajoutees', issue)}</p>
|
||||
{:else if $generation.annulee === 'commande'}
|
||||
<p data-issue role="status">{t('generation.issue.annuleeCommande')}</p>
|
||||
{:else if $generation.annulee === 'seance'}
|
||||
<p data-issue role="status">{t('generation.issue.annuleeSeance')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
.question {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
}
|
||||
|
||||
.issues {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
12
src/interface/generation/travailleur.js
Normal file
12
src/interface/generation/travailleur.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le point d'entrée du travailleur de génération (§ 5.10) : un module qui
|
||||
// s'exécute dans la portée du travailleur, self, et la branche sur la
|
||||
// recherche du moteur par brancherTravailleur. src/main.js seul l'importe,
|
||||
// par « ?worker&inline » : Vite l'inclut dans la page, et la coquille, qui
|
||||
// charge la page par file://, n'a aucun fichier de travailleur à charger.
|
||||
// L'exécuteur qui lui parle est celui d'executeur_travailleur.js.
|
||||
import { brancherTravailleur } from '../../application/generation_travailleur.js';
|
||||
|
||||
brancherTravailleur(self);
|
||||
322
src/interface/import/Apercu.svelte
Normal file
322
src/interface/import/Apercu.svelte
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le contenu d'un aperçu d'import (§ 10.1, § 10.2), celui que rend
|
||||
// modeleApercu (modele.js) : la source — le nom du fichier, ou le texte
|
||||
// collé —, l'encodage et le séparateur nommés, le refus global avec sa
|
||||
// cause et son remède, l'association des colonnes et ses ambiguïtés, les
|
||||
// lignes valides, les lignes refusées et leur motif, les orthographes
|
||||
// fondues, les doublons, les homonymes d'une mise à jour, les cellules sur
|
||||
// plusieurs lignes. Le rapport le reprend pour l'aperçu appliqué, ses
|
||||
// refus remplacés par ceux du résumé.
|
||||
//
|
||||
// modele : le modèle ; source : { origine: 'fichier'|'collage', nom } ;
|
||||
// participants : ceux de la charge, qui nomment les personnes désignées ;
|
||||
// refusees : les lignes refusées à montrer, celles du modèle par défaut ;
|
||||
// choisir(champ, rang) : tranche une ambiguïté, null pour un aperçu figé.
|
||||
// Chaque donnée — nom de fichier, en-tête, cellule, rang, compte — occupe
|
||||
// son propre nœud de texte.
|
||||
import { getContext } from 'svelte';
|
||||
import { COLONNES_DES_LIGNES, motifLigne, nommer } from './modele.js';
|
||||
|
||||
let { modele, source, participants = [], refusees = null, choisir = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const motif = motifLigne(t);
|
||||
|
||||
const refuseesMontrees = $derived(refusees ?? modele.refusees);
|
||||
const global = $derived(modele.global);
|
||||
</script>
|
||||
|
||||
<div class="apercu">
|
||||
<dl class="source">
|
||||
{#if source.origine === 'fichier'}
|
||||
<dt>{t('import.apercu.fichier')}</dt>
|
||||
<dd data-fichier>{source.nom}</dd>
|
||||
{:else}
|
||||
<dt data-collage>{t('import.apercu.collage')}</dt>
|
||||
<dd></dd>
|
||||
{/if}
|
||||
{#if modele.encodage !== null}
|
||||
<dt>{t('import.apercu.encodage')}</dt>
|
||||
<dd data-encodage>{t(modele.encodage)}</dd>
|
||||
{/if}
|
||||
{#if modele.separateur !== null}
|
||||
<dt>{t('import.apercu.separateur')}</dt>
|
||||
<dd data-separateur>{t(modele.separateur)}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
{#if global !== null}
|
||||
<div class="refus" role="status" data-refus={global.cle}>
|
||||
<p>{t(global.cle, global.details)}</p>
|
||||
{#if !global.remede.dansLaCause}
|
||||
<p>{t(global.remede.cle, global.remede.details)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if modele.colonnes.length > 0}
|
||||
<table class="colonnes" data-colonnes>
|
||||
<caption>{t('import.apercu.colonnes')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('import.apercu.entete')}</th>
|
||||
<th scope="col">{t('import.apercu.devient')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each modele.colonnes as colonne (colonne.rang)}
|
||||
<tr data-colonne={colonne.rang}>
|
||||
<td data-entete>{colonne.entete}</td>
|
||||
<td data-devient>{t(colonne.cle)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
{#if choisir !== null}
|
||||
{#each modele.ambigus as ambigu (ambigu.champ)}
|
||||
<fieldset class="ambigu" data-champ={`choix-${ambigu.champ}`}>
|
||||
<legend><span>{t('import.ambiguite.question')}</span> <span>{t(ambigu.cle)}</span></legend>
|
||||
{#each ambigu.rangs as { rang, entete } (rang)}
|
||||
<label>
|
||||
<input type="radio" name={`choix-${ambigu.champ}`} value={rang} onchange={() => choisir(ambigu.champ, rang)} />
|
||||
<span>{entete}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if global === null || modele.valides > 0}
|
||||
<p data-valides>
|
||||
<span data-nombre>{format.nombre(modele.valides)}</span>
|
||||
<span>{t('import.apercu.valides', { n: modele.valides })}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if modele.lignes.length > 0}
|
||||
<div class="defile">
|
||||
<table class="lignes" data-lignes>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('import.apercu.ligne')}</th>
|
||||
{#each COLONNES_DES_LIGNES as { champ, cle } (champ)}
|
||||
<th scope="col">{t(cle)}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each modele.lignes as { ligne, cellules } (ligne)}
|
||||
<tr data-ligne={ligne}>
|
||||
<th scope="row">{format.nombre(ligne)}</th>
|
||||
{#each cellules as { champ, texte, cle } (champ)}
|
||||
<td data-cellule={champ}>{cle === null ? (texte ?? '') : t(cle)}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if refuseesMontrees.length > 0}
|
||||
<table class="refusees" data-refusees>
|
||||
<caption>{t('import.apercu.refusees')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('import.apercu.ligne')}</th>
|
||||
<th scope="col">{t('import.apercu.motif')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each refuseesMontrees as { ligne, code, details } (ligne)}
|
||||
<tr data-ligne={ligne}>
|
||||
<th scope="row">{format.nombre(ligne)}</th>
|
||||
<td>
|
||||
<span data-motif>{motif(code, details)}</span>
|
||||
{#if details.participants !== undefined}
|
||||
<span>{t('import.rapport.designees')}</span>
|
||||
{#each nommer(participants, details.participants) as personne (personne.id)}
|
||||
<span class="personne" data-participant={personne.id}>
|
||||
{#if personne.prenom !== null}<span>{personne.prenom}</span>{/if}
|
||||
<span>{personne.nom}</span>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
{#if modele.fusions.length > 0}
|
||||
<section class="bloc">
|
||||
<h4>{t('import.apercu.fusions')}</h4>
|
||||
<ul data-fusions>
|
||||
{#each modele.fusions as fusion, rang (rang)}
|
||||
<li data-fusion>
|
||||
<span>{t('import.apercu.affichee')}</span>
|
||||
<strong data-affichee>{fusion.affichee}</strong>
|
||||
<ul>
|
||||
{#each fusion.orthographes as orthographe (orthographe.texte)}
|
||||
<li data-orthographe>
|
||||
<span data-texte>{orthographe.texte}</span>
|
||||
<span data-nombre>{format.nombre(orthographe.lignes)}</span>
|
||||
<span>{t('import.apercu.orthographeLignes', { n: orthographe.lignes })}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if modele.doublons.length > 0}
|
||||
<section class="bloc">
|
||||
<h4>{t('import.apercu.doublons')}</h4>
|
||||
<ul data-doublons>
|
||||
{#each modele.doublons as doublon, rang (rang)}
|
||||
<li data-doublon>
|
||||
<span>{t('import.apercu.doublonLignes')}</span>
|
||||
{#each doublon.lignes as ligne (ligne)}
|
||||
<span data-nombre>{format.nombre(ligne)}</span>
|
||||
{/each}
|
||||
{#if doublon.existants.length > 0}
|
||||
<span>{t('import.apercu.doublonExistants')}</span>
|
||||
{#each nommer(participants, doublon.existants) as personne (personne.id)}
|
||||
<span class="personne" data-participant={personne.id}>
|
||||
{#if personne.prenom !== null}<span>{personne.prenom}</span>{/if}
|
||||
<span>{personne.nom}</span>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if modele.homonymes.length > 0}
|
||||
<section class="bloc">
|
||||
<h4>{t('import.apercu.homonymes')}</h4>
|
||||
<ul>
|
||||
{#each modele.homonymes as homonyme (homonyme.ligne)}
|
||||
<li data-homonyme={homonyme.ligne}>
|
||||
<span>{t('import.apercu.ligne')}</span>
|
||||
<span>{format.nombre(homonyme.ligne)}</span>
|
||||
{#each nommer(participants, homonyme.participants) as personne (personne.id)}
|
||||
<span class="personne" data-participant={personne.id}>
|
||||
{#if personne.prenom !== null}<span>{personne.prenom}</span>{/if}
|
||||
<span>{personne.nom}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if modele.multilignes.length > 0}
|
||||
<section class="bloc">
|
||||
<h4>{t('import.apercu.multilignes')}</h4>
|
||||
<ul>
|
||||
{#each modele.multilignes as multiligne (multiligne.ligne)}
|
||||
<li data-multiligne={multiligne.ligne}>
|
||||
<span>{t('import.apercu.ligne')}</span>
|
||||
<span>{format.nombre(multiligne.ligne)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.apercu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
.source {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--espace-1) var(--espace-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.refus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-refus);
|
||||
color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
.refus p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
padding-bottom: var(--espace-1);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.defile {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ambigu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
|
||||
.bloc h4 {
|
||||
margin: 0 0 var(--espace-1);
|
||||
}
|
||||
|
||||
.bloc ul {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-5);
|
||||
}
|
||||
|
||||
.personne {
|
||||
display: inline-flex;
|
||||
gap: var(--espace-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
824
src/interface/import/Import.navigateur.test.js
Normal file
824
src/interface/import/Import.navigateur.test.js
Normal file
|
|
@ -0,0 +1,824 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'import des participants dans un vrai moteur de rendu (§ 10.1, § 10.2,
|
||||
// § 10.3, § 2.1 étape 2, § 9) : l'aperçu obligatoire, l'encodage et le
|
||||
// séparateur nommés, l'association par en-tête, l'ambiguïté, les refus
|
||||
// d'une ligne et les refus globaux, le rapport, le CSV des refus, les trois
|
||||
// modes, l'import selon l'état, le collage. Chaque épreuve monte App par
|
||||
// monterApp, sur le système de fichiers simulé, ouvre un événement et sa
|
||||
// section d'import ; le dialogue de choix sert, dans l'ordre, les fichiers
|
||||
// que l'épreuve prépare, et le dialogue d'enregistrement relève ce qu'il
|
||||
// reçoit (contrat de données, « Les dialogues de fichiers »).
|
||||
//
|
||||
// Les octets de chaque cas se construisent ici ; seuls les CSV livrés se
|
||||
// lisent, par leur adresse (?url) et fetch : ?raw les lirait en UTF-8 et
|
||||
// détruirait la variante windows-1252. Les valeurs attendues viennent des
|
||||
// mêmes implémentations que l'écran sollicite — decoder, apercevoir,
|
||||
// exporterRefus et libelle, bilanRemplacement —, appelées ici une seconde
|
||||
// fois (§ 19.1). Les noms d'épreuve sont inventés.
|
||||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { apercevoir } from '../../csv/apercu.js';
|
||||
import { decoder } from '../../csv/encodage.js';
|
||||
import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js';
|
||||
import { decouper } from '../../csv/lecture.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { visible } from '../../../test/navigateur/rendu.js';
|
||||
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
|
||||
import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url';
|
||||
import urlGrande from '../../../exemples/participants_demo_grande.csv?url';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { ENCODAGES, SEPARATEURS } from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
|
||||
const BASE = 'soiree';
|
||||
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
|
||||
const OUVERTURE_TEMOIN = '\u{2045}';
|
||||
const FERMETURE_TEMOIN = '\u{2046}';
|
||||
|
||||
// Les octets d'un CSV livré, tels que le fichier les porte.
|
||||
const lireLivre = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
|
||||
|
||||
// Un texte en UTF-8 avec marque, en UTF-16 LE ou BE avec marque.
|
||||
const enUtf8 = (texte) => Uint8Array.from([...MARQUE_UTF8, ...new TextEncoder().encode(texte)]);
|
||||
function enUtf16(texte, ordre) {
|
||||
const octets = [ordre === 'le' ? 0xff : 0xfe, ordre === 'le' ? 0xfe : 0xff];
|
||||
for (let i = 0; i < texte.length; i += 1) {
|
||||
const unite = texte.charCodeAt(i);
|
||||
const fort = unite >> 8;
|
||||
const faible = unite & 0xff;
|
||||
octets.push(...(ordre === 'le' ? [faible, fort] : [fort, faible]));
|
||||
}
|
||||
return Uint8Array.from(octets);
|
||||
}
|
||||
|
||||
// Un participant de la charge d'épreuve.
|
||||
const personne = (id, nom, prenom = null, autres = {}) => ({
|
||||
id, nom, prenom, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres,
|
||||
});
|
||||
|
||||
// Les montages de l'épreuve en cours, démontés après elle ; le collecteur
|
||||
// n'a rien reçu pendant l'épreuve.
|
||||
let montes = [];
|
||||
let erreursAvant = 0;
|
||||
beforeEach(() => {
|
||||
erreursAvant = globalThis.__gttErreurs?.length ?? 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
vi.restoreAllMocks();
|
||||
expect(globalThis.__gttErreurs?.slice(erreursAvant) ?? [], 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Un montage dont l'événement « Soirée des Merles » est ouvert sur sa
|
||||
* section d'import : les participants donnés, en écriture sauf demande,
|
||||
* préparé par prepare(seance, generation) avant que l'espion se pose. Le
|
||||
* dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève
|
||||
* enregistres et rend le nom proposé.
|
||||
*/
|
||||
async function ouvrir({ participants = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) {
|
||||
const charge = creerCharge({ id: 'epreuve-import', nom: 'Soirée des Merles', siegesParDefaut, tours: 2 });
|
||||
charge.participants = participants;
|
||||
charge.prochainsIds.participant = participants.length + 1;
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue });
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
await m.seance.passerEnEcriture();
|
||||
if (prepare !== null) await prepare(m.seance, m.app.generation());
|
||||
if (!ecriture && m.seance.etat().mode === 'ecriture') await m.seance.passerEnLecture();
|
||||
m.ui.allerA('import');
|
||||
flushSync();
|
||||
const aServir = [];
|
||||
const enregistres = [];
|
||||
m.fs.choisirFichierAImporter = async () => aServir.shift() ?? null;
|
||||
m.fs.enregistrerSous = async (nomPropose, octets) => {
|
||||
enregistres.push({ nomPropose, octets });
|
||||
return nomPropose;
|
||||
};
|
||||
const espion = vi.spyOn(m.seance, 'executer');
|
||||
return { m, aServir, enregistres, espion };
|
||||
}
|
||||
|
||||
const section = ({ m }) => m.racine.querySelector('[data-section="import"]');
|
||||
const dans = (e, selecteur) => section(e).querySelector(selecteur);
|
||||
const tous = (e, selecteur) => [...section(e).querySelectorAll(selecteur)];
|
||||
const commande = (e, nom) => dans(e, `[data-commande="${nom}"]`);
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const ecritures = ({ m }) => m.trace.length;
|
||||
const imports = ({ espion }) => espion.mock.calls.filter(([nom]) => nom === 'importerParticipants');
|
||||
|
||||
// Sert un fichier au dialogue et le choisit : l'aperçu s'ouvre sur lui.
|
||||
async function choisir(e, nom, octets) {
|
||||
e.aServir.push({ nom, octets });
|
||||
commande(e, 'choisirFichier').click();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe(nom));
|
||||
}
|
||||
|
||||
// Remet un texte comme le collage de la grille (I5) : ui.ouvrirImport,
|
||||
// puis ui.allerA('import').
|
||||
async function coller(e, texte) {
|
||||
e.m.ui.ouvrirImport({ texte, origine: 'collage' });
|
||||
e.m.ui.allerA('import');
|
||||
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-collage]')).not.toBeNull());
|
||||
}
|
||||
|
||||
// Choisit un mode d'import.
|
||||
function choisirMode(e, mode) {
|
||||
dans(e, `[data-champ="mode"] input[value="${mode}"]`).click();
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait :
|
||||
// rend ce que rend seance.executer, ou le refus.
|
||||
async function importer(e) {
|
||||
const avant = imports(e).length;
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(imports(e).length).toBe(avant + 1));
|
||||
const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === 'importerParticipants').at(-1);
|
||||
try {
|
||||
const rendu = await resultat.value;
|
||||
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
|
||||
return { rendu, refus: null };
|
||||
} catch (erreur) {
|
||||
await vi.waitFor(() => expect(dans(e, `[data-refus="${erreur.code}"]`)).not.toBeNull());
|
||||
return { rendu: null, refus: erreur };
|
||||
}
|
||||
}
|
||||
|
||||
// Les lignes valides montrées sous un conteneur : le texte de chaque
|
||||
// cellule, dans l'ordre.
|
||||
const lignesMontrees = (conteneur) =>
|
||||
[...conteneur.querySelectorAll('[data-lignes] [data-ligne]')].map((ligne) =>
|
||||
[...ligne.querySelectorAll('[data-cellule]')].map((cellule) => cellule.textContent));
|
||||
const cellule = (conteneur, ligne, champ) =>
|
||||
conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`).textContent;
|
||||
const refusee = (conteneur, ligne) => conteneur.querySelector(`[data-refusees] [data-ligne="${ligne}"]`);
|
||||
const association = (conteneur) =>
|
||||
[...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [
|
||||
colonne.querySelector('[data-entete]').textContent,
|
||||
colonne.querySelector('[data-devient]').textContent,
|
||||
]);
|
||||
const participantsDe = ({ m }) => m.seance.etat().charge.participants;
|
||||
|
||||
// Les entrées du journal de l'événement, 0 avant le premier geste.
|
||||
function entrees({ m }) {
|
||||
const texte = m.fs.contenu('documents', `${BASE}${SUFFIXES.journal}`);
|
||||
return texte === null ? 0 : lireJournal(texte).entrees.length;
|
||||
}
|
||||
|
||||
// Les comptes du rapport, chacun lu à son [data-compte] ; et ceux qu'il
|
||||
// doit afficher : le résumé que rend seance.executer, par format.nombre.
|
||||
const COMPTES_DU_RAPPORT = ['ajoutes', 'misAJour', 'inchanges', 'retires', 'reservationsRetirees'];
|
||||
const comptesAffiches = (e) =>
|
||||
COMPTES_DU_RAPPORT.map((nom) => [nom, dans(e, `[data-rapport] [data-compte="${nom}"] [data-nombre]`).textContent]);
|
||||
const comptesDu = (resume) => COMPTES_DU_RAPPORT.map((nom) => [nom, format.nombre(resume[nom])]);
|
||||
|
||||
// Un glissement sur la fenêtre, portant un fichier ou un texte seul : rend
|
||||
// l'événement distribué, dont defaultPrevented dit s'il a été accepté.
|
||||
function glisser({ fichier = null, texte = null }) {
|
||||
const transfert = new DataTransfer();
|
||||
if (fichier !== null) transfert.items.add(fichier);
|
||||
if (texte !== null) transfert.setData('text/plain', texte);
|
||||
const evenement = new DragEvent('dragover', { dataTransfer: transfert, bubbles: true, cancelable: true });
|
||||
document.body.dispatchEvent(evenement);
|
||||
return evenement;
|
||||
}
|
||||
|
||||
// Amène un élément dans la fenêtre, puis dit s'il y est rendu visible
|
||||
// (test/navigateur/rendu.js).
|
||||
function vu(element) {
|
||||
element.scrollIntoView({ block: 'center' });
|
||||
return visible(element);
|
||||
}
|
||||
|
||||
describe('l’aperçu est obligatoire (§ 10.1)', () => {
|
||||
test('choisir les cas limites ouvre l’aperçu, sans rien écrire ; déposer un fichier sur la fenêtre ouvre son aperçu ; aucun contrôle hors de l’import n’importe', async () => {
|
||||
const e = await ouvrir();
|
||||
const avant = ecritures(e);
|
||||
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
|
||||
expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
|
||||
const octets = enUtf8('nom;prenom\r\nOmbrelle;Iris\r\n;Théo\r\n');
|
||||
const deposee = new File([octets], 'deposee.csv', { type: 'text/csv' });
|
||||
// Le survol qui porte un fichier est accepté, sans quoi le navigateur ne
|
||||
// remet pas le dépôt et ouvre le fichier à la place de l'application ;
|
||||
// un survol sans fichier ne l'est pas.
|
||||
expect(glisser({ fichier: deposee }).defaultPrevented).toBe(true);
|
||||
expect(glisser({ texte: 'Ombrelle' }).defaultPrevented).toBe(false);
|
||||
const transfert = new DataTransfer();
|
||||
transfert.items.add(deposee);
|
||||
document.body.dispatchEvent(new DragEvent('drop', { dataTransfer: transfert, bubbles: true, cancelable: true }));
|
||||
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe('deposee.csv'));
|
||||
|
||||
const controles = tous(e, '[data-apercu] button, [data-apercu] input').filter(
|
||||
(element) => element.dataset.commande !== 'importer' && element.dataset.commande !== 'fermerApercu',
|
||||
);
|
||||
expect(controles.length).toBeGreaterThan(0);
|
||||
for (const controle of controles) {
|
||||
controle.click();
|
||||
flushSync();
|
||||
}
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
expect(imports(e)).toEqual([]);
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodage et séparateur nommés (§ 10.1, § 19.3)', () => {
|
||||
test('les cas limites, puis leur variante windows-1252 : encodage et séparateur de decoder et de l’aperçu, « Héloïse » intacte, mêmes lignes', async () => {
|
||||
const e = await ouvrir();
|
||||
const vus = [];
|
||||
for (const [nom, url] of [['participants_cas_limites.csv', urlCasLimites], ['participants_cas_limites_cp1252.csv', urlCasLimites1252]]) {
|
||||
const octets = await lireLivre(url);
|
||||
await choisir(e, nom, octets);
|
||||
const { texte, encodage } = decoder(octets);
|
||||
expect(dans(e, '[data-encodage]').textContent).toBe(t(ENCODAGES.get(encodage)));
|
||||
expect(dans(e, '[data-separateur]').textContent).toBe(t(SEPARATEURS.get(apercevoir(texte).separateur)));
|
||||
const apercu = dans(e, '[data-apercu]');
|
||||
expect(apercu.textContent).toContain('Héloïse');
|
||||
expect(apercu.textContent).not.toContain('Héloïse');
|
||||
vus.push({ encodage, lignes: lignesMontrees(apercu) });
|
||||
}
|
||||
expect(vus.map(({ encodage }) => encodage)).toEqual(['utf-8-bom', 'windows-1252']);
|
||||
expect(dans(e, '[data-separateur]').textContent).toBe(t('import.separateur.virgule'));
|
||||
expect(vus[0].lignes.length).toBe(13);
|
||||
expect(vus[1].lignes).toEqual(vus[0].lignes);
|
||||
});
|
||||
|
||||
test('un même texte en UTF-16 LE puis BE, à marque : chacun nommé, mêmes lignes', async () => {
|
||||
const e = await ouvrir();
|
||||
const texte = 'nom;prenom;appartenance\r\nOmbrelle;Iris;Club des Merles\r\nPervenche;Benoît;Grisaille\r\n';
|
||||
const vus = [];
|
||||
for (const ordre of ['le', 'be']) {
|
||||
await choisir(e, `liste-${ordre}.csv`, enUtf16(texte, ordre));
|
||||
expect(dans(e, '[data-encodage]').textContent).toBe(t(ENCODAGES.get(`utf-16${ordre}`)));
|
||||
vus.push(lignesMontrees(dans(e, '[data-apercu]')));
|
||||
}
|
||||
expect(vus[0].length).toBe(2);
|
||||
expect(vus[1]).toEqual(vus[0]);
|
||||
expect(vus[0][1].slice(0, 3)).toEqual(['Pervenche', 'Benoît', 'Grisaille']);
|
||||
});
|
||||
|
||||
test('un fichier d’une seule colonne, en-tête « nom » : « une seule colonne », « Ombrelle, Iris » entière', async () => {
|
||||
const e = await ouvrir();
|
||||
await choisir(e, 'noms.csv', enUtf8('nom\r\nPervenche\r\nOmbrelle, Iris\r\nLacasse\r\n'));
|
||||
expect(dans(e, '[data-separateur]').textContent).toBe('une seule colonne');
|
||||
expect(cellule(dans(e, '[data-apercu]'), 3, 'nom')).toBe('Ombrelle, Iris');
|
||||
});
|
||||
});
|
||||
|
||||
describe('les colonnes s’associent par leur en-tête (§ 10.1)', () => {
|
||||
test('deux fichiers aux colonnes permutées, « NOM », « Équipe », « Rôle » : même association, mêmes participants ; « Allergies » non reconnue', async () => {
|
||||
const e = await ouvrir();
|
||||
const premier = 'NOM;Équipe;Rôle;Allergies\r\nOmbrelle;Club des Merles;animation;noix\r\nPervenche;Grisaille;;\r\n';
|
||||
const second = 'Allergies;Rôle;Équipe;NOM\r\nnoix;animation;Club des Merles;Ombrelle\r\n;;Grisaille;Pervenche\r\n';
|
||||
const vus = [];
|
||||
for (const [nom, texte] of [['premier.csv', premier], ['second.csv', second]]) {
|
||||
await choisir(e, nom, enUtf8(texte));
|
||||
const apercu = dans(e, '[data-apercu]');
|
||||
vus.push({ association: new Map(association(apercu)), lignes: lignesMontrees(apercu) });
|
||||
}
|
||||
expect(vus[0].association.size).toBe(4);
|
||||
expect(vus[1].association).toEqual(vus[0].association);
|
||||
expect(vus[0].association.get('NOM')).toBe(t('import.champ.nom'));
|
||||
expect(vus[0].association.get('Équipe')).toBe(t('import.champ.appartenance'));
|
||||
expect(vus[0].association.get('Rôle')).toBe(t('import.champ.titrePressenti'));
|
||||
expect(vus[0].association.get('Allergies')).toBe(t('import.colonne.nonReconnue'));
|
||||
expect(vus[1].lignes).toEqual(vus[0].lignes);
|
||||
expect(vus[0].lignes.length).toBe(2);
|
||||
});
|
||||
|
||||
test('« organisation » et « entreprise » : l’aperçu demande laquelle vaut appartenance ; importer est refusé à côté de la commande tant qu’elle demeure ; le choix l’associe, et l’import passe', async () => {
|
||||
const e = await ouvrir();
|
||||
await choisir(e, 'ambigu.csv', enUtf8('nom;organisation;entreprise\r\nOmbrelle;Club des Merles;Grisaille\r\n'));
|
||||
const question = dans(e, '[data-champ="choix-appartenance"]');
|
||||
expect(vu(question)).toBe(true);
|
||||
expect(texteDe(question)).toContain('organisation');
|
||||
expect(texteDe(question)).toContain('entreprise');
|
||||
const avant = ecritures(e);
|
||||
const { refus } = await importer(e);
|
||||
expect(refus.code).toBe('AMBIGUITE');
|
||||
const posee = dans(e, '[data-refus="AMBIGUITE"]');
|
||||
expect(commande(e, 'importer').parentElement.contains(posee)).toBe(true);
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
|
||||
question.querySelector('input[value="2"]').click();
|
||||
flushSync();
|
||||
expect(new Map(association(dans(e, '[data-apercu]'))).get('entreprise')).toBe(t('import.champ.appartenance'));
|
||||
expect(dans(e, '[data-champ="choix-appartenance"]')).toBeNull();
|
||||
const { rendu } = await importer(e);
|
||||
expect(rendu.resume.ajoutes).toBe(1);
|
||||
expect(participantsDe(e).map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Ombrelle', 'Grisaille']]);
|
||||
});
|
||||
|
||||
test('sous « Remplacer », l’ambiguïté refuse l’import à côté de la commande, sans ouvrir la fenêtre ; le choix fait, la fenêtre s’ouvre', async () => {
|
||||
const e = await ouvrir({ participants: QUATRE() });
|
||||
await choisir(e, 'ambigu.csv', enUtf8('nom;organisation;entreprise\r\nOmbrelle;Club des Merles;Grisaille\r\n'));
|
||||
choisirMode(e, 'remplacer');
|
||||
const avant = ecritures(e);
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-refus="AMBIGUITE"]')).not.toBeNull());
|
||||
expect(commande(e, 'importer').parentElement.contains(dans(e, '[data-refus="AMBIGUITE"]'))).toBe(true);
|
||||
expect(dans(e, 'dialog')).toBeNull();
|
||||
expect(imports(e)).toEqual([]);
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
|
||||
dans(e, '[data-champ="choix-appartenance"] input[value="1"]').click();
|
||||
flushSync();
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
|
||||
dans(e, 'dialog [data-commande="renoncer"]').click();
|
||||
await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull());
|
||||
expect(imports(e)).toEqual([]);
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exclu : une liste fermée (§ 10.1)', () => {
|
||||
test('« Oui », « X », « 0 » et vide acceptés ; « peut-être » refuse sa ligne, le motif citant la valeur ; les autres s’importent', async () => {
|
||||
const e = await ouvrir();
|
||||
await choisir(
|
||||
e,
|
||||
'exclus.csv',
|
||||
enUtf8('nom;exclu\r\nOmbrelle;Oui\r\nPervenche;X\r\nLacasse;0\r\nFauvelle;\r\nMorillon;peut-être\r\n'),
|
||||
);
|
||||
const apercu = dans(e, '[data-apercu]');
|
||||
expect(lignesMontrees(apercu).length).toBe(4);
|
||||
expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').length).toBe(1);
|
||||
expect(texteDe(refusee(apercu, 6))).toContain('« peut-être »');
|
||||
await importer(e);
|
||||
expect(participantsDe(e).map(({ nom, exclu }) => [nom, exclu])).toEqual([
|
||||
['Ombrelle', true], ['Pervenche', true], ['Lacasse', false], ['Fauvelle', false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Un fichier par refus global, ses octets, et la clé du remède attendue.
|
||||
const REFUS_GLOBAUX = [
|
||||
['AUCUN_ENTETE', enUtf8('alpha;beta\r\nOmbrelle;Iris\r\n'), 'import.remede.aucunEntete'],
|
||||
['NOM_NON_ASSOCIE', enUtf8('prenom;notes\r\nIris;x\r\n'), 'import.remede.nomNonAssocie'],
|
||||
['AUCUNE_LIGNE_VALIDE', enUtf8('nom;prenom\r\n;Iris\r\n'), 'import.remede.aucuneLigneValide'],
|
||||
['UTF16_SANS_MARQUE', Uint8Array.from([0x6e, 0x00, 0x6f, 0x00, 0x6d, 0x00]), 'ENREGISTRER_EN_UTF8'],
|
||||
['UTF16_INVALIDE', Uint8Array.from([0xff, 0xfe, 0x6e, 0x00, 0x6f]), 'ENREGISTRER_EN_UTF8'],
|
||||
['UTF8_INVALIDE', Uint8Array.from([...MARQUE_UTF8, 0x6e, 0x6f, 0xe9, 0x6d]), 'ENREGISTRER_EN_UTF8'],
|
||||
['CARACTERE_NUL', enUtf8('nom\r\nOmbrelle\u{0}\r\n'), 'ENREGISTRER_EN_UTF8'],
|
||||
['GUILLEMET_OUVERT', enUtf8('nom;prenom\r\n"Ombrelle;Iris\r\n'), 'import.remede.guillemetOuvert'],
|
||||
['SEPARATEUR_INTROUVABLE', enUtf8('nom;prenom\r\nOmbrelle;Iris;x\r\nPervenche\r\n'), 'import.remede.separateurIntrouvable'],
|
||||
];
|
||||
|
||||
describe('les refus globaux (§ 10.1)', () => {
|
||||
test('un fichier par cas : la cause et le remède de la table affichés, aucune commande d’import, rien d’écrit', async () => {
|
||||
const e = await ouvrir();
|
||||
expect(REFUS_GLOBAUX.length).toBe(9);
|
||||
const avant = ecritures(e);
|
||||
for (const [code, octets, remede] of REFUS_GLOBAUX) {
|
||||
await choisir(e, `${code}.csv`, octets);
|
||||
const pose = dans(e, `[data-apercu] [data-refus="${code}"]`);
|
||||
expect(pose, code).not.toBeNull();
|
||||
expect(vu(pose), code).toBe(true);
|
||||
const texte = pose.textContent;
|
||||
expect(texte, code).toContain(t(remede, { ligne: 2 }));
|
||||
expect(texte.length, code).toBeGreaterThan(t(remede, { ligne: 2 }).length);
|
||||
expect(commande(e, 'importer'), code).toBeNull();
|
||||
}
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
|
||||
test('un collage qui porte le caractère nul : la cause et le remède du collage', async () => {
|
||||
const e = await ouvrir();
|
||||
const avant = ecritures(e);
|
||||
await coller(e, 'nom\nOmbrelle\u{0}\n');
|
||||
const pose = dans(e, '[data-apercu] [data-refus="CARACTERE_NUL"]');
|
||||
expect(pose.textContent).toContain(t('CARACTERE_NUL'));
|
||||
expect(pose.textContent).toContain(t('import.remede.caractereNulCollage'));
|
||||
expect(commande(e, 'importer')).toBeNull();
|
||||
expect(dans(e, '[data-encodage]')).toBeNull();
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('une ligne fausse n’arrête pas le reste (§ 10.1, § 2.1)', () => {
|
||||
test('la grande démonstration, « peut-être » à l’enregistrement 213 : 259 lignes valides, la ligne 213 et sa valeur ; l’import en ajoute 259', async () => {
|
||||
const e = await ouvrir();
|
||||
const { texte } = decoder(await lireLivre(urlGrande));
|
||||
const enregistrements = texte.split('\r\n');
|
||||
expect(enregistrements[212]).toMatch(/;non;$/);
|
||||
enregistrements[212] = enregistrements[212].replace(/;non;$/, ';peut-être;');
|
||||
await choisir(e, 'grande.csv', enUtf8(enregistrements.join('\r\n')));
|
||||
const apercu = dans(e, '[data-apercu]');
|
||||
expect(dans(e, '[data-valides] [data-nombre]').textContent).toBe('259');
|
||||
expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['213']);
|
||||
expect(texteDe(refusee(apercu, 213))).toContain('213');
|
||||
expect(texteDe(refusee(apercu, 213))).toContain(t('EXCLU_INCONNU', { valeur: 'peut-être' }));
|
||||
const { rendu } = await importer(e);
|
||||
expect(rendu.resume.ajoutes).toBe(259);
|
||||
expect(participantsDe(e).length).toBe(259);
|
||||
});
|
||||
|
||||
test('le rang : la ligne refusée des cas limites, sans nom, s’affiche 13, son enregistrement, et non 14, sa ligne de texte', async () => {
|
||||
const e = await ouvrir();
|
||||
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
|
||||
const apercu = dans(e, '[data-apercu]');
|
||||
expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['13']);
|
||||
expect(texteDe(refusee(apercu, 13))).toContain('13');
|
||||
expect(texteDe(refusee(apercu, 13))).toContain(t('NOM_ABSENT'));
|
||||
expect(refusee(apercu, 14)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('le rapport (§ 10.1, § 19.3)', () => {
|
||||
test('« Coopérative Bleue » existante, « cooperative bleue » et « COOPÉRATIVE BLEUE » lues : un groupe affiché, ses trois orthographes et leurs comptes, dans l’aperçu puis le rapport', async () => {
|
||||
const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Coopérative Bleue' })] });
|
||||
await choisir(
|
||||
e,
|
||||
'bleue.csv',
|
||||
enUtf8('nom;prenom;appartenance\r\nPervenche;Théo;cooperative bleue\r\nLacasse;Ondine;COOPÉRATIVE BLEUE\r\nFauvelle;;\r\n'),
|
||||
);
|
||||
const attendu = [['Coopérative Bleue', '0'], ['cooperative bleue', '1'], ['COOPÉRATIVE BLEUE', '1']];
|
||||
const groupes = (conteneur) =>
|
||||
[...conteneur.querySelectorAll('[data-fusion]')].map((fusion) => ({
|
||||
affichee: fusion.querySelector('[data-affichee]').textContent,
|
||||
orthographes: [...fusion.querySelectorAll('[data-orthographe]')].map((o) => [
|
||||
o.querySelector('[data-texte]').textContent,
|
||||
o.querySelector('[data-nombre]').textContent,
|
||||
]),
|
||||
}));
|
||||
expect(groupes(dans(e, '[data-apercu]'))).toEqual([{ affichee: 'Coopérative Bleue', orthographes: attendu }]);
|
||||
await importer(e);
|
||||
const rapport = dans(e, '[data-rapport]');
|
||||
expect(vu(rapport.querySelector('h3'))).toBe(true);
|
||||
expect(groupes(rapport)).toEqual([{ affichee: 'Coopérative Bleue', orthographes: attendu }]);
|
||||
for (const texte of tous(e, '[data-orthographe] [data-texte]')) expect(texte.textContent.trim()).not.toBe('');
|
||||
});
|
||||
|
||||
test('les cas limites : au moins un groupe et une ligne refusée, dans l’aperçu puis le rapport ; aucune appartenance vide listée', async () => {
|
||||
const e = await ouvrir();
|
||||
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
|
||||
for (const conteneur of ['[data-apercu]', null]) {
|
||||
if (conteneur === null) await importer(e);
|
||||
const ici = dans(e, conteneur ?? '[data-rapport]');
|
||||
expect(ici.querySelectorAll('[data-fusion]').length).toBeGreaterThanOrEqual(1);
|
||||
expect(ici.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1);
|
||||
const orthographes = [...ici.querySelectorAll('[data-orthographe] [data-texte]')];
|
||||
expect(orthographes.length).toBeGreaterThan(0);
|
||||
for (const texte of orthographes) expect(texte.textContent.trim()).not.toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('le CSV des lignes refusées (§ 10.1)', () => {
|
||||
test('« Enregistrer les lignes refusées » remet les octets d’exporterRefus de src/csv, motif de libelle ; corrigé et réimporté, la ligne entre, ligne et motif non reconnues', async () => {
|
||||
const e = await ouvrir();
|
||||
const octets = await lireLivre(urlCasLimites);
|
||||
await choisir(e, 'participants_cas_limites.csv', octets);
|
||||
commande(e, 'enregistrerRefus').click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
const apercu = apercevoir(decoder(octets).texte, { participants: [], mode: 'ajouter' });
|
||||
expect(e.enregistres[0].nomPropose).toBe(`${BASE}-refus.csv`);
|
||||
expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle)]);
|
||||
|
||||
const corrige = decoder(e.enregistres[0].octets).texte.replace('\r\n;Clotilde;', '\r\nRavenelle;Clotilde;');
|
||||
expect(corrige).toContain('Ravenelle;Clotilde;');
|
||||
await choisir(e, 'corrige.csv', enUtf8(corrige));
|
||||
const vu = new Map(association(dans(e, '[data-apercu]')));
|
||||
expect(vu.get('ligne')).toBe(t('import.colonne.nonReconnue'));
|
||||
expect(vu.get('motif')).toBe(t('import.colonne.nonReconnue'));
|
||||
await importer(e);
|
||||
expect(participantsDe(e).map(({ nom, prenom }) => [nom, prenom])).toEqual([['Ravenelle', 'Clotilde']]);
|
||||
});
|
||||
|
||||
test('après une mise à jour qui refuse des lignes, le rapport remet exporterRefus(apercu, libelle, resume.refusees), refus de la mise à jour compris', async () => {
|
||||
const e = await ouvrir({
|
||||
participants: [personne(1, 'Lacasse', 'Ondine'), personne(2, 'Lacasse', 'Odile'), personne(3, 'Ombrelle', 'Iris')],
|
||||
});
|
||||
await choisir(e, 'maj.csv', enUtf8('nom;prenom\r\nLacasse;\r\nOmbrelle;Irène\r\n;Théo\r\n'));
|
||||
const { rendu } = await importer(e);
|
||||
const { apercu } = imports(e).at(-1)[1];
|
||||
expect(rendu.resume.refusees.map(({ code }) => code)).toEqual(['PLUSIEURS_CORRESPONDENT', 'NOM_ABSENT']);
|
||||
commande(e, 'enregistrerRefus').click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle, rendu.resume.refusees)]);
|
||||
});
|
||||
|
||||
test('en langue témoin, le motif affiché porte les marques, la colonne motif du CSV aucune', async () => {
|
||||
const e = await ouvrir({ langue: 'temoin' });
|
||||
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
|
||||
const motif = refusee(dans(e, '[data-apercu]'), 13).querySelector('[data-motif]').textContent;
|
||||
expect(motif.startsWith(OUVERTURE_TEMOIN) && motif.endsWith(FERMETURE_TEMOIN)).toBe(true);
|
||||
commande(e, 'enregistrerRefus').click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
const { texte } = decoder(e.enregistres[0].octets);
|
||||
const [entete, ...refus] = decouper(texte, ';').enregistrements;
|
||||
const rang = entete.indexOf('motif');
|
||||
expect(rang).toBeGreaterThan(0);
|
||||
expect(refus.length).toBe(1);
|
||||
expect(refus[0][rang]).toBe(libelle('NOM_ABSENT', { ligne: 13, valeur: null }));
|
||||
expect(texte).not.toContain(OUVERTURE_TEMOIN);
|
||||
expect(texte).not.toContain(FERMETURE_TEMOIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('une seule entrée, des doublons signalés, une mise à jour qui n’efface pas (§ 10.1)', () => {
|
||||
test('les 260 lignes de la grande démonstration : une entrée du journal, un état écrit', async () => {
|
||||
// Un premier geste pose le journal, et l'entrée de reprise qui l'ouvre.
|
||||
const e = await ouvrir({ prepare: (seance) => seance.executer('creerTable', { forme: 'ronde' }) });
|
||||
expect(entrees(e)).toBeGreaterThan(0);
|
||||
await choisir(e, 'participants_demo_grande.csv', await lireLivre(urlGrande));
|
||||
const avant = ecritures(e);
|
||||
const entreesAvant = entrees(e);
|
||||
const { rendu } = await importer(e);
|
||||
expect(rendu.resume.ajoutes).toBe(260);
|
||||
expect(entrees(e)).toBe(entreesAvant + 1);
|
||||
const etats = e.m.trace.slice(avant).filter(([primitive, , chemin]) => primitive === 'ecrireAtomique' && chemin === `${BASE}${SUFFIXES.etat}`);
|
||||
expect(etats.length).toBe(1);
|
||||
});
|
||||
|
||||
test('deux lignes au même triplet et une égale à un existant : deux doublons ; en mode Ajouter, choisi, trois personnes de plus, d’identifiants distincts', async () => {
|
||||
const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Grisaille' })] });
|
||||
await choisir(
|
||||
e,
|
||||
'doublons.csv',
|
||||
enUtf8('nom;prenom;appartenance\r\nPervenche;Théo;Club des Merles\r\npervenche;THÉO;club des merles\r\nOmbrelle;Iris;Grisaille\r\n'),
|
||||
);
|
||||
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
|
||||
choisirMode(e, 'ajouter');
|
||||
expect(tous(e, '[data-apercu] [data-doublon]').length).toBe(2);
|
||||
const { rendu } = await importer(e);
|
||||
expect(rendu.resume.ajoutes).toBe(3);
|
||||
const ids = participantsDe(e).map(({ id }) => id);
|
||||
expect(ids.length).toBe(4);
|
||||
expect(new Set(ids).size).toBe(4);
|
||||
});
|
||||
|
||||
test('mettre à jour : aucun courriel vidé ; une ligne qui désigne deux personnes, refusée, les deux nommées ; une seconde ligne pour la même personne, refusée, la première nommée', async () => {
|
||||
const e = await ouvrir({
|
||||
participants: [
|
||||
personne(1, 'Ombrelle', 'Iris', { courriel: 'iris@exemple.test' }),
|
||||
personne(2, 'Lacasse', 'Ondine', { courriel: 'ondine@exemple.test' }),
|
||||
personne(3, 'Lacasse', 'Odile'),
|
||||
personne(4, 'Pervenche', 'Théo', { courriel: 'theo@exemple.test' }),
|
||||
],
|
||||
});
|
||||
await choisir(
|
||||
e,
|
||||
'noms_prenoms.csv',
|
||||
enUtf8('nom;prenom\r\nOmbrelle;Iris\r\nLacasse;\r\nPervenche;Théo\r\nPervenche;\r\nMorillon;Aurèle\r\n'),
|
||||
);
|
||||
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
|
||||
const { rendu } = await importer(e);
|
||||
expect(rendu.resume).toMatchObject({ ajoutes: 1, misAJour: 0, inchanges: 2, retires: 0, reservationsRetirees: 0 });
|
||||
expect(comptesAffiches(e)).toEqual(comptesDu(rendu.resume));
|
||||
const courriels = new Map(participantsDe(e).map(({ id, courriel }) => [id, courriel]));
|
||||
expect([courriels.get(1), courriels.get(2), courriels.get(4)]).toEqual(['iris@exemple.test', 'ondine@exemple.test', 'theo@exemple.test']);
|
||||
const rapport = dans(e, '[data-rapport]');
|
||||
const plusieurs = refusee(rapport, 3);
|
||||
expect(texteDe(plusieurs)).toContain(t('PLUSIEURS_CORRESPONDENT', { participants: [2, 3] }));
|
||||
expect([...plusieurs.querySelectorAll('[data-participant]')].map((p) => [p.dataset.participant, texteDe(p)])).toEqual([
|
||||
['2', 'Ondine Lacasse'],
|
||||
['3', 'Odile Lacasse'],
|
||||
]);
|
||||
const deja = refusee(rapport, 5);
|
||||
expect(texteDe(deja)).toContain(t('DEJA_DESIGNE', { premiereLigne: 4 }));
|
||||
});
|
||||
});
|
||||
|
||||
// Deux tables de trois places, puis une génération enregistrée jusqu'à
|
||||
// compter nombre propositions.
|
||||
async function avecPropositions(seance, generation, nombre) {
|
||||
await seance.executer('creerTable', { forme: 'ronde' });
|
||||
await seance.executer('creerTable', { forme: 'ronde' });
|
||||
for (let essai = 0; essai < 5 && seance.etat().charge.propositions.length < nombre; essai += 1) {
|
||||
await generation.generer({ ...generation.reglagesProposes(), nombre, arret: 500 });
|
||||
}
|
||||
expect(seance.etat().charge.propositions.length).toBeGreaterThanOrEqual(nombre);
|
||||
}
|
||||
|
||||
const QUATRE = () => [
|
||||
personne(1, 'Ombrelle', 'Iris', { appartenance: 'Coopérative Bleue' }),
|
||||
personne(2, 'Pervenche', 'Théo', { appartenance: 'Grisaille' }),
|
||||
personne(3, 'Lacasse', 'Ondine', { appartenance: 'Club des Merles' }),
|
||||
personne(4, 'Fauvelle', 'Aurèle'),
|
||||
];
|
||||
|
||||
describe('remplacer annonce ce qu’il détruit (§ 10.1)', () => {
|
||||
test('l’aperçu se recalcule sous remplacer, la fenêtre porte les quatre comptes ; Annuler n’écrit rien ; Confirmer, une entrée, des identifiants neufs', async () => {
|
||||
const e = await ouvrir({
|
||||
participants: QUATRE(),
|
||||
siegesParDefaut: 3,
|
||||
prepare: async (seance, generation) => {
|
||||
await avecPropositions(seance, generation, 2);
|
||||
const [table] = seance.etat().charge.tables;
|
||||
await seance.executer('reserver', { participant: 1, table: table.id, siege: null, portee: 'tous', tour: null });
|
||||
await seance.executer('designer', { participant: 2, table: table.id, siege: null, libelle: 'Animation' });
|
||||
},
|
||||
});
|
||||
await choisir(e, 'remplace.csv', enUtf8('nom;prenom;appartenance\r\nTisserin;Basilie;cooperative bleue\r\nMorillon;Aurèle;\r\n'));
|
||||
expect(tous(e, '[data-apercu] [data-fusion]').length).toBe(1);
|
||||
choisirMode(e, 'remplacer');
|
||||
expect(tous(e, '[data-apercu] [data-fusion]').length).toBe(0);
|
||||
expect(tous(e, '[data-apercu] [data-doublon]').length).toBe(0);
|
||||
expect(cellule(dans(e, '[data-apercu]'), 2, 'appartenance')).toBe('cooperative bleue');
|
||||
|
||||
const charge = e.m.seance.etat().charge;
|
||||
const bilan = bilanRemplacement(charge);
|
||||
expect(bilan.reservations).toBeGreaterThan(0);
|
||||
expect(bilan.titresPourvus).toBeGreaterThan(0);
|
||||
expect(bilan.propositions).toBeGreaterThanOrEqual(2);
|
||||
const attendus = [
|
||||
t('import.remplacer.participants', { n: bilan.participants }),
|
||||
t('import.remplacer.reservations', { n: bilan.reservations }),
|
||||
t('import.remplacer.titres', { n: bilan.titresPourvus }),
|
||||
t('import.remplacer.propositions', { n: bilan.propositions }),
|
||||
];
|
||||
const avant = ecritures(e);
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
|
||||
expect([...dans(e, 'dialog').querySelectorAll('li')].map((li) => li.textContent)).toEqual(attendus);
|
||||
dans(e, 'dialog [data-commande="renoncer"]').click();
|
||||
await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull());
|
||||
expect(imports(e)).toEqual([]);
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
|
||||
const prochain = charge.prochainsIds.participant;
|
||||
const entreesAvant = entrees(e);
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
|
||||
dans(e, 'dialog [data-commande="confirmer"]').click();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
|
||||
expect(imports(e).length).toBe(1);
|
||||
expect(imports(e)[0][1].mode).toBe('remplacer');
|
||||
expect(entrees(e)).toBe(entreesAvant + 1);
|
||||
const { resume } = await e.espion.mock.results.at(-1).value;
|
||||
expect(resume).toMatchObject({ ajoutes: 2, misAJour: 0, inchanges: 0, retires: 4, reservationsRetirees: 2 });
|
||||
expect(comptesAffiches(e)).toEqual(comptesDu(resume));
|
||||
const apres = participantsDe(e);
|
||||
expect(apres.map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Tisserin', 'cooperative bleue'], ['Morillon', null]]);
|
||||
for (const { id } of apres) expect(id).toBeGreaterThanOrEqual(prochain);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importer selon l’état (§ 10.1, § 9)', () => {
|
||||
const FICHIER = 'nom;prenom\r\nMorillon;Aurèle\r\n';
|
||||
|
||||
test('brouillon et proposé sans avertissement ; retenu avec IMPORT_EN_RETENU dans le rendu de seance.executer', async () => {
|
||||
const preparations = [
|
||||
['brouillon', null],
|
||||
['propose', (seance, generation) => avecPropositions(seance, generation, 1)],
|
||||
[
|
||||
'retenu',
|
||||
async (seance, generation) => {
|
||||
await avecPropositions(seance, generation, 1);
|
||||
await seance.executer('retenirProposition', { id: seance.etat().charge.propositions[0].id });
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [etat, prepare] of preparations) {
|
||||
const e = await ouvrir({ participants: QUATRE(), siegesParDefaut: 3, prepare });
|
||||
expect(e.m.seance.etat().charge.evenement.etat).toBe(etat);
|
||||
await choisir(e, 'tardif.csv', enUtf8(FICHIER));
|
||||
const { rendu } = await importer(e);
|
||||
const codes = rendu.avertissements.map(({ code }) => code);
|
||||
if (etat === 'retenu') expect(codes, etat).toContain('IMPORT_EN_RETENU');
|
||||
else expect(codes, etat).not.toContain('IMPORT_EN_RETENU');
|
||||
expect(participantsDe(e).length, etat).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
test('bloqué : l’aperçu s’ouvre, l’import est refusé à côté de la commande, le message portant « Débloquer », rien d’écrit', async () => {
|
||||
const e = await ouvrir({
|
||||
participants: QUATRE(),
|
||||
prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }),
|
||||
});
|
||||
const avant = ecritures(e);
|
||||
await choisir(e, 'tardif.csv', enUtf8(FICHIER));
|
||||
expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
|
||||
const { refus } = await importer(e);
|
||||
expect(refus.code).toBe('LECTURE_SEULE');
|
||||
const pose = dans(e, '[data-refus="LECTURE_SEULE"]');
|
||||
expect(commande(e, 'importer').parentElement.contains(pose)).toBe(true);
|
||||
expect(texteDe(pose)).toContain('Débloquer');
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
expect(participantsDe(e).length).toBe(4);
|
||||
});
|
||||
|
||||
test('en lecture : l’aperçu s’ouvre, l’import est refusé avec « Modifier », rien d’écrit', async () => {
|
||||
const e = await ouvrir({ ecriture: false });
|
||||
expect(e.m.seance.etat().mode).toBe('lecture');
|
||||
const avant = ecritures(e);
|
||||
await choisir(e, 'tardif.csv', enUtf8(FICHIER));
|
||||
expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
|
||||
const { refus } = await importer(e);
|
||||
expect(refus.code).toBe('MODE_LECTURE');
|
||||
const pose = dans(e, '[data-refus="MODE_LECTURE"]');
|
||||
expect(pose.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le collage en bloc passe par le même analyseur (§ 10.2, § 14.10)', () => {
|
||||
const TEXTE = [
|
||||
['nom', 'prenom', 'appartenance'],
|
||||
['Ombrelle', 'Iris', '"Club\tdes ""Merles"""'],
|
||||
['Pervenche', 'Benoît', 'Grisaille'],
|
||||
].map((champs) => champs.join('\t')).join('\n');
|
||||
|
||||
test('un texte à tabulations collé : un aperçu sans encodage ; l’import rend les mêmes participants, champ par champ, que le même texte servi en fichier', async () => {
|
||||
const collage = await ouvrir();
|
||||
await coller(collage, TEXTE);
|
||||
expect(dans(collage, '[data-encodage]')).toBeNull();
|
||||
expect(dans(collage, '[data-separateur]').textContent).toBe(t('import.separateur.tabulation'));
|
||||
await importer(collage);
|
||||
const fichier = await ouvrir();
|
||||
await choisir(fichier, 'colle.csv', enUtf8(TEXTE));
|
||||
await importer(fichier);
|
||||
expect(participantsDe(collage).length).toBe(2);
|
||||
expect(participantsDe(collage)[0].appartenance).toBe('Club\tdes "Merles"');
|
||||
expect(participantsDe(collage)[1].prenom).toBe('Benoît');
|
||||
expect(participantsDe(collage)).toEqual(participantsDe(fichier));
|
||||
});
|
||||
|
||||
test('sans sa ligne d’en-tête : AUCUN_ENTETE, puis « Lire les colonnes dans l’ordre de la grille » en fait nom, prénom et appartenance', async () => {
|
||||
const e = await ouvrir();
|
||||
await coller(e, TEXTE.split('\n').slice(1).join('\n'));
|
||||
const pose = dans(e, '[data-refus="AUCUN_ENTETE"]');
|
||||
expect(pose.textContent).toContain(t('import.remede.aucunEnteteCollage'));
|
||||
commande(e, 'ordreDeLaGrille').click();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-refus]')).toBeNull());
|
||||
expect(association(dans(e, '[data-apercu]')).map(([, devient]) => devient)).toEqual([
|
||||
t('import.champ.nom'), t('import.champ.prenom'), t('import.champ.appartenance'),
|
||||
]);
|
||||
expect(tous(e, '[data-apercu] [data-lignes] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['2', '3']);
|
||||
await importer(e);
|
||||
expect(participantsDe(e).map(({ nom, prenom }) => [nom, prenom])).toEqual([['Ombrelle', 'Iris'], ['Pervenche', 'Benoît']]);
|
||||
});
|
||||
|
||||
test('une liste de noms d’une colonne : SEPARATEUR_INTROUVABLE, la même offre, chaque ligne entière en nom', async () => {
|
||||
const e = await ouvrir();
|
||||
await coller(e, 'Ombrelle\nLacasse, Ondine\nPervenche');
|
||||
const pose = dans(e, '[data-refus="SEPARATEUR_INTROUVABLE"]');
|
||||
expect(pose.textContent).toContain(t('import.remede.separateurIntrouvableCollage'));
|
||||
commande(e, 'ordreDeLaGrille').click();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-refus]')).toBeNull());
|
||||
expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle', 'Lacasse, Ondine', 'Pervenche']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les cas du § 10.3', () => {
|
||||
// Chacun des neuf cas, lu dans un conteneur : l'aperçu, puis le rapport.
|
||||
function neufCas(conteneur) {
|
||||
const colonnes = new Map(association(conteneur));
|
||||
expect(colonnes.get('Téléphone'), 'colonne non reconnue').toBe(t('import.colonne.nonReconnue'));
|
||||
expect(cellule(conteneur, 6, 'appartenance'), 'sans appartenance').toBe('');
|
||||
expect(cellule(conteneur, 7, 'exclu'), 'exclusion').toBe(t('import.exclu.oui'));
|
||||
expect(cellule(conteneur, 8, 'notes'), 'champ cité avec le séparateur').toBe('végétarien; sans noix, ni arachides');
|
||||
expect(cellule(conteneur, 9, 'notes'), 'champ cité sur deux lignes').toMatch(/premier tour\r?\nrepart/);
|
||||
const doublons = [...conteneur.querySelectorAll('[data-doublon]')].map((d) =>
|
||||
[...d.querySelectorAll('[data-nombre]')].map((n) => n.textContent));
|
||||
expect(doublons, 'doublon').toEqual([['2', '10']]);
|
||||
expect(cellule(conteneur, 11, 'prenom'), 'sans prénom').toBe('');
|
||||
const fusions = [...conteneur.querySelectorAll('[data-fusion] [data-orthographe] [data-texte]')].map((o) => o.textContent);
|
||||
expect(fusions, 'paire d’orthographes').toEqual(['Société des Hérons', 'Societe des herons']);
|
||||
expect(refusee(conteneur, 13), 'ligne refusée').not.toBeNull();
|
||||
}
|
||||
|
||||
test('l’aperçu et le rapport des cas limites montrent chacun des neuf cas', async () => {
|
||||
const e = await ouvrir();
|
||||
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
|
||||
neufCas(dans(e, '[data-apercu]'));
|
||||
await importer(e);
|
||||
neufCas(dans(e, '[data-rapport]'));
|
||||
});
|
||||
|
||||
test('la variante windows-1252 nomme windows-1252 et la virgule, et rend les mêmes participants', async () => {
|
||||
const parVariante = [];
|
||||
for (const [nom, url] of [['participants_cas_limites.csv', urlCasLimites], ['participants_cas_limites_cp1252.csv', urlCasLimites1252]]) {
|
||||
const e = await ouvrir();
|
||||
await choisir(e, nom, await lireLivre(url));
|
||||
parVariante.push({ encodage: dans(e, '[data-encodage]').textContent, separateur: dans(e, '[data-separateur]').textContent });
|
||||
await importer(e);
|
||||
parVariante.at(-1).participants = participantsDe(e);
|
||||
}
|
||||
expect(parVariante[1].encodage).toBe(t('import.encodage.windows1252'));
|
||||
expect(parVariante[1].separateur).toBe(t('import.separateur.virgule'));
|
||||
expect(parVariante[0].participants.length).toBe(13);
|
||||
expect(parVariante[1].participants).toEqual(parVariante[0].participants);
|
||||
});
|
||||
});
|
||||
73
src/interface/import/Rapport.svelte
Normal file
73
src/interface/import/Rapport.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le rapport d'un import appliqué (§ 10.1) : les comptes du résumé que
|
||||
// rend seance.executer — ajoutés, mis à jour, inchangés, retirés,
|
||||
// réservations retirées —, puis l'aperçu appliqué, ses lignes refusées
|
||||
// remplacées par resume.refusees : celles de l'aperçu, puis celles de la
|
||||
// mise à jour, chacune à son rang, avec les personnes qu'elle désigne. Un
|
||||
// import qui n'a rien écrit le dit.
|
||||
//
|
||||
// rapport : { modele, source, resume, libelle } ; participants : ceux de
|
||||
// la charge écrite. Les commandes du rapport sont celles de la section.
|
||||
import { getContext } from 'svelte';
|
||||
import Apercu from './Apercu.svelte';
|
||||
import { refuseesDe } from './modele.js';
|
||||
|
||||
let { rapport, participants } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
|
||||
// Chaque compte du résumé et la clé de son libellé, dans l'ordre du résumé.
|
||||
const COMPTES = [
|
||||
['ajoutes', 'import.rapport.ajoutes'],
|
||||
['misAJour', 'import.rapport.misAJour'],
|
||||
['inchanges', 'import.rapport.inchanges'],
|
||||
['retires', 'import.rapport.retires'],
|
||||
['reservationsRetirees', 'import.rapport.reservationsRetirees'],
|
||||
];
|
||||
|
||||
const refusees = $derived(refuseesDe(rapport.resume.refusees));
|
||||
</script>
|
||||
|
||||
<section class="rapport" data-rapport>
|
||||
<h3>{t('import.rapport.titre')}</h3>
|
||||
{#if rapport.libelle === null}
|
||||
<p>{t('import.rapport.rienEcrit')}</p>
|
||||
{/if}
|
||||
<ul class="comptes">
|
||||
{#each COMPTES as [nom, cle] (nom)}
|
||||
<li data-compte={nom}>
|
||||
<span data-nombre>{format.nombre(rapport.resume[nom])}</span>
|
||||
<span>{t(cle, { n: rapport.resume[nom] })}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<Apercu modele={rapport.modele} source={rapport.source} {participants} {refusees} />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.rapport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
padding-top: var(--espace-3);
|
||||
border-top: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comptes {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-5);
|
||||
}
|
||||
|
||||
.comptes li {
|
||||
display: flex;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
</style>
|
||||
293
src/interface/import/SectionImport.svelte
Normal file
293
src/interface/import/SectionImport.svelte
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section de l'import des participants (§ 10.1, § 10.2, § 2.1 étape
|
||||
// 2). Un fichier choisi par le dialogue de la façade, un fichier déposé
|
||||
// sur la fenêtre, ou un texte que le collage de la grille remet par
|
||||
// ui.ouvrirImport, ouvre l'aperçu ; rien ne s'importe hors de lui.
|
||||
// L'aperçu se calcule par apercevoir contre les participants de la
|
||||
// charge, sous le mode choisi et le choix des colonnes ambiguës : il
|
||||
// n'écrit rien, et s'ouvre en lecture comme sur un plan bloqué. Seule
|
||||
// « Importer » écrit, par seance.executer('importerParticipants', …), et
|
||||
// son refus se pose à côté d'elle ; « Remplacer » passe d'abord par la
|
||||
// fenêtre à confirmer, qui porte les quatre comptes de bilanRemplacement,
|
||||
// et ne l'ouvre que sur un import que la commande accepterait.
|
||||
// Le rapport lit le résumé que la séance rend.
|
||||
//
|
||||
// Le CSV des refus — de l'aperçu, ou du résumé dans le rapport — vient
|
||||
// d'exporterRefus de src/application/csv.js, dont le motif est celui de
|
||||
// libelle ; la façade l'enregistre sous ‹base›-refus.csv. La détection
|
||||
// d'encodage ou de séparateur ne se force pas : l'opérateur ferme l'aperçu
|
||||
// et corrige le fichier.
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { apercevoir, bilanRemplacement, decoder, exporterRefus } from '../../application/csv.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Apercu from './Apercu.svelte';
|
||||
import Rapport from './Rapport.svelte';
|
||||
import { MODES_D_IMPORT, avecEntetesDeLaGrille, modeleApercu, modePropose } from './modele.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const facade = getContext('facade');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
|
||||
// La source de l'aperçu : { origine: 'fichier'|'collage', nom, encodage,
|
||||
// texte, refusDecodage } ; texte null sous un refus de decoder.
|
||||
let source = $state.raw(null);
|
||||
// { champ: rang } qui tranche une ambiguïté de l'en-tête.
|
||||
let choix = $state.raw({});
|
||||
let mode = $state('ajouter');
|
||||
// Le dernier import appliqué : { modele, source, apercu, resume, libelle }.
|
||||
let rapport = $state.raw(null);
|
||||
// La fenêtre de « Remplacer » ouverte : { apercu, elements }.
|
||||
let confirmation = $state.raw(null);
|
||||
// Le chemin où le CSV des refus vient d'être enregistré.
|
||||
let enregistre = $state(null);
|
||||
|
||||
const participants = $derived($seance.charge?.participants ?? []);
|
||||
const apercu = $derived(
|
||||
source === null || source.refusDecodage !== null ? null : apercevoir(source.texte, { participants, choix, mode }),
|
||||
);
|
||||
const modele = $derived(
|
||||
source === null ? null : modeleApercu({ encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }),
|
||||
);
|
||||
// Le texte relu dans l'ordre de la grille, offert à un collage seul.
|
||||
const grille = $derived(source?.origine === 'collage' && apercu !== null ? avecEntetesDeLaGrille(source.texte, apercu) : null);
|
||||
|
||||
// Une source neuve : le choix des colonnes repart de rien, le mode
|
||||
// proposé suit la liste, le rapport précédent se retire.
|
||||
function ouvrirSource(nouvelle) {
|
||||
source = nouvelle;
|
||||
choix = {};
|
||||
mode = modePropose(untrack(() => participants));
|
||||
rapport = null;
|
||||
confirmation = null;
|
||||
enregistre = null;
|
||||
}
|
||||
|
||||
// Les octets d'un fichier : leur texte décodé, ou le refus de decoder,
|
||||
// qui tient lieu d'aperçu. Toute autre erreur est une faute, qui remonte.
|
||||
function lireFichier({ nom, octets }) {
|
||||
try {
|
||||
const { texte, encodage } = decoder(octets);
|
||||
ouvrirSource({ origine: 'fichier', nom, encodage, texte, refusDecodage: null });
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
ouvrirSource({ origine: 'fichier', nom, encodage: null, texte: null, refusDecodage: { code: erreur.code, details: erreur.details } });
|
||||
}
|
||||
}
|
||||
|
||||
async function choisirFichier() {
|
||||
const choisi = await facade.choisirCsv();
|
||||
if (choisi !== null) lireFichier(choisi);
|
||||
}
|
||||
|
||||
// Un fichier déposé sur la fenêtre ouvre son aperçu, comme un fichier
|
||||
// choisi ; un glissement sans fichier n'est pas retenu.
|
||||
function survoler(evenement) {
|
||||
if (evenement.dataTransfer?.types?.includes('Files')) evenement.preventDefault();
|
||||
}
|
||||
|
||||
async function deposer(evenement) {
|
||||
const fichier = evenement.dataTransfer?.files?.[0];
|
||||
if (fichier === undefined) return;
|
||||
evenement.preventDefault();
|
||||
lireFichier({ nom: fichier.name, octets: new Uint8Array(await fichier.arrayBuffer()) });
|
||||
}
|
||||
|
||||
// Le texte que le collage de la grille remet : pris une fois, il ouvre
|
||||
// l'aperçu d'un collage, sans encodage.
|
||||
$effect(() => {
|
||||
if ($ui.importEnAttente === null) return;
|
||||
untrack(() => {
|
||||
const pris = ui.prendreImport();
|
||||
if (pris !== null) ouvrirSource({ origine: 'collage', nom: null, encodage: null, texte: pris.texte, refusDecodage: null });
|
||||
});
|
||||
});
|
||||
|
||||
function choisirColonne(champ, rang) {
|
||||
choix = { ...choix, [champ]: rang };
|
||||
}
|
||||
|
||||
function lireDansLOrdreDeLaGrille() {
|
||||
source = { ...source, texte: grille };
|
||||
choix = {};
|
||||
}
|
||||
|
||||
function fermerApercu() {
|
||||
source = null;
|
||||
enregistre = null;
|
||||
}
|
||||
|
||||
// L'aperçu appliqué par la séance, dans son mode ; le rapport lit le
|
||||
// résumé rendu, et l'aperçu se ferme.
|
||||
async function appliquer(applique, sonMode, depuis) {
|
||||
const { libelle, resume } = await seance.executer('importerParticipants', { apercu: applique, mode: sonMode });
|
||||
rapport = {
|
||||
modele: modeleApercu({ encodage: depuis.encodage, apercu: applique }),
|
||||
source: { origine: depuis.origine, nom: depuis.nom },
|
||||
apercu: applique,
|
||||
resume,
|
||||
libelle,
|
||||
};
|
||||
source = null;
|
||||
enregistre = null;
|
||||
}
|
||||
|
||||
// « Importer » : l'aperçu montré, dans le mode choisi. « Remplacer »
|
||||
// ouvre d'abord sa fenêtre, sauf quand le geste serait refusé : le refus
|
||||
// de la séance, celui de son état, puis celui de la commande essayée sur
|
||||
// la charge sans rien écrire (appliquerCommande, le calcul même que
|
||||
// seance.executer applique) — une ambiguïté non tranchée, AMBIGUITE —
|
||||
// se posent à côté de la commande, et la fenêtre n'annonce jamais une
|
||||
// destruction qui n'aura pas lieu.
|
||||
async function importer() {
|
||||
const applique = apercu;
|
||||
const sonMode = mode;
|
||||
const depuis = source;
|
||||
if (sonMode !== 'remplacer') {
|
||||
await appliquer(applique, sonMode, depuis);
|
||||
return;
|
||||
}
|
||||
const { refus, charge } = seance.etat();
|
||||
if (refus !== null) throw refus;
|
||||
appliquerCommande('importerParticipants', charge, { apercu: applique, mode: sonMode });
|
||||
const bilan = bilanRemplacement(charge);
|
||||
confirmation = {
|
||||
apercu: applique,
|
||||
depuis,
|
||||
elements: [
|
||||
t('import.remplacer.participants', { n: bilan.participants }),
|
||||
t('import.remplacer.reservations', { n: bilan.reservations }),
|
||||
t('import.remplacer.titres', { n: bilan.titresPourvus }),
|
||||
t('import.remplacer.propositions', { n: bilan.propositions }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function confirmerRemplacement() {
|
||||
await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis);
|
||||
confirmation = null;
|
||||
}
|
||||
|
||||
// Le CSV des refus, remis à la façade sous ‹base›-refus.csv.
|
||||
async function enregistrerRefus(octets) {
|
||||
enregistre = await facade.enregistrerSous(`${seance.etat().evenement.base}-refus.csv`, octets);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window ondragover={survoler} ondrop={deposer} />
|
||||
|
||||
<section class="import" data-section="import">
|
||||
<h2>{t('coquille.section.import')}</h2>
|
||||
<p>{t('import.consigne')}</p>
|
||||
<div class="commandes">
|
||||
<Commande cle="import.commande.choisirFichier" nom="choisirFichier" executer={choisirFichier} />
|
||||
</div>
|
||||
|
||||
{#if source !== null}
|
||||
{#key source}
|
||||
<section class="apercu" data-apercu>
|
||||
<h3>{t('import.apercu.titre')}</h3>
|
||||
{#if modele.global === null}
|
||||
<fieldset class="modes" data-champ="mode">
|
||||
<legend>{t('import.mode.titre')}</legend>
|
||||
{#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)}
|
||||
<label>
|
||||
<input type="radio" name={`${uid}-mode`} value={valeur} bind:group={mode} />
|
||||
<span>{t(cle)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{/if}
|
||||
<Apercu {modele} source={{ origine: source.origine, nom: source.nom }} {participants} choisir={choisirColonne} />
|
||||
<div class="commandes">
|
||||
{#if grille !== null}
|
||||
<Commande cle="import.commande.ordreDeLaGrille" nom="ordreDeLaGrille" executer={lireDansLOrdreDeLaGrille} />
|
||||
{/if}
|
||||
{#if modele.global === null}
|
||||
<Commande cle="import.commande.importer" nom="importer" executer={importer} />
|
||||
{/if}
|
||||
{#if modele.refusees.length > 0}
|
||||
<Commande
|
||||
cle="import.commande.enregistrerRefus"
|
||||
nom="enregistrerRefus"
|
||||
executer={() => enregistrerRefus(exporterRefus(apercu))}
|
||||
/>
|
||||
{/if}
|
||||
<Commande cle="import.commande.fermerApercu" nom="fermerApercu" executer={fermerApercu} />
|
||||
</div>
|
||||
{#if enregistre !== null}
|
||||
<p><span>{t('import.refusEnregistres')}</span> <span data-enregistre>{enregistre}</span></p>
|
||||
{/if}
|
||||
</section>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#if rapport !== null}
|
||||
<Rapport {rapport} {participants} />
|
||||
<div class="commandes">
|
||||
{#if rapport.resume.refusees.length > 0}
|
||||
<Commande
|
||||
cle="import.commande.enregistrerRefus"
|
||||
nom="enregistrerRefus"
|
||||
executer={() => enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))}
|
||||
/>
|
||||
{/if}
|
||||
<Commande cle="import.commande.fermerRapport" nom="fermerRapport" executer={() => (rapport = null)} />
|
||||
</div>
|
||||
{#if enregistre !== null}
|
||||
<p><span>{t('import.refusEnregistres')}</span> <span data-enregistre>{enregistre}</span></p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if confirmation !== null}
|
||||
<Confirmation
|
||||
titre="import.remplacer.titre"
|
||||
elements={confirmation.elements}
|
||||
surConfirmer={confirmerRemplacement}
|
||||
surAnnuler={() => (confirmation = null)}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.apercu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
padding-top: var(--espace-3);
|
||||
border-top: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.modes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
</style>
|
||||
309
src/interface/import/modele.js
Normal file
309
src/interface/import/modele.js
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : ce que l'aperçu montre,
|
||||
// en clés de libellés et en données, d'un fichier décodé, d'un fichier que
|
||||
// decoder refuse, ou d'un collage. Le texte vient de la traduction ; ce
|
||||
// module ne choisit que les clés, et ne recalcule rien de ce que l'aperçu de
|
||||
// src/csv établit : association, lignes, fusions et doublons s'y lisent tels
|
||||
// quels.
|
||||
import { CHAMPS } from '../../application/csv.js';
|
||||
|
||||
/** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses
|
||||
* quatre temps. */
|
||||
export const ENCODAGES = new Map([
|
||||
['utf-8-bom', 'import.encodage.utf8Marque'],
|
||||
['utf-16le', 'import.encodage.utf16le'],
|
||||
['utf-16be', 'import.encodage.utf16be'],
|
||||
['utf-8', 'import.encodage.utf8'],
|
||||
['windows-1252', 'import.encodage.windows1252'],
|
||||
]);
|
||||
|
||||
/** La clé du nom de chaque séparateur de l'aperçu ; null : le fichier n'a
|
||||
* qu'une colonne. */
|
||||
export const SEPARATEURS = new Map([
|
||||
[';', 'import.separateur.pointVirgule'],
|
||||
[',', 'import.separateur.virgule'],
|
||||
['\t', 'import.separateur.tabulation'],
|
||||
[null, 'import.separateur.uneColonne'],
|
||||
]);
|
||||
|
||||
// La clé du nom de chaque champ, dans l'ordre de CHAMPS.
|
||||
const CLES_DES_CHAMPS = new Map([
|
||||
['nom', 'import.champ.nom'],
|
||||
['prenom', 'import.champ.prenom'],
|
||||
['appartenance', 'import.champ.appartenance'],
|
||||
['courriel', 'import.champ.courriel'],
|
||||
['titre_pressenti', 'import.champ.titrePressenti'],
|
||||
['exclu', 'import.champ.exclu'],
|
||||
['notes', 'import.champ.notes'],
|
||||
]);
|
||||
|
||||
// La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas.
|
||||
const CLES_D_EXCLU = new Map([
|
||||
[true, 'import.exclu.oui'],
|
||||
[false, 'import.exclu.non'],
|
||||
]);
|
||||
|
||||
// Le nom de chaque champ dans ChampsLus, celui de l'aperçu de src/csv.
|
||||
const CHAMPS_LUS = new Map([['titre_pressenti', 'titrePressenti']]);
|
||||
|
||||
/** Les trois modes de l'import (§ 10.1), dans l'ordre où l'écran les
|
||||
* offre, et la clé de leur nom. */
|
||||
export const MODES_D_IMPORT = Object.freeze(
|
||||
[
|
||||
['ajouter', 'import.mode.ajouter'],
|
||||
['mettreAJour', 'import.mode.mettreAJour'],
|
||||
['remplacer', 'import.mode.remplacer'],
|
||||
].map(([mode, cle]) => Object.freeze({ mode, cle })),
|
||||
);
|
||||
|
||||
/** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de
|
||||
* CHAMPS, et la clé de son nom. */
|
||||
export const COLONNES_DES_LIGNES = Object.freeze(CHAMPS.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
|
||||
|
||||
// Le remède d'un refus de decoder : le code que ses détails portent, que le
|
||||
// texte du refus compose déjà (contrat « Refus et avertissements »).
|
||||
const REFUS_DE_DECODER = new Set(['UTF16_SANS_MARQUE', 'UTF16_INVALIDE', 'UTF8_INVALIDE', 'CARACTERE_NUL']);
|
||||
|
||||
// La clé du remède de chaque refus global de l'aperçu. Un caractère nul n'y
|
||||
// arrive que d'un texte collé : celui d'un fichier, decoder le refuse.
|
||||
const REMEDES_DE_L_APERCU = new Map([
|
||||
['CARACTERE_NUL', 'import.remede.caractereNulCollage'],
|
||||
['AUCUN_ENTETE', 'import.remede.aucunEntete'],
|
||||
['NOM_NON_ASSOCIE', 'import.remede.nomNonAssocie'],
|
||||
['AUCUNE_LIGNE_VALIDE', 'import.remede.aucuneLigneValide'],
|
||||
['GUILLEMET_OUVERT', 'import.remede.guillemetOuvert'],
|
||||
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'],
|
||||
]);
|
||||
|
||||
// Le remède d'un collage auquel l'aperçu offre l'ordre de la grille.
|
||||
const REMEDES_DE_LA_GRILLE = new Map([
|
||||
['AUCUN_ENTETE', 'import.remede.aucunEnteteCollage'],
|
||||
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvableCollage'],
|
||||
]);
|
||||
|
||||
// La valeur d'une table de clés, ou RangeError qui nomme ce qui manque :
|
||||
// jamais une clé devinée.
|
||||
function dans(table, valeur, quoi) {
|
||||
if (!table.has(valeur)) throw new RangeError(`import : ${quoi} inconnu ${JSON.stringify(valeur)}`);
|
||||
return table.get(valeur);
|
||||
}
|
||||
|
||||
// Vrai quand l'aperçu offre la lecture dans l'ordre de la grille : un texte
|
||||
// non blanc sans en-tête reconnu, ou une seule colonne sans séparateur.
|
||||
function offreLaGrille(apercu) {
|
||||
if (apercu.refusGlobal === 'AUCUN_ENTETE') return apercu.entetes.length > 0;
|
||||
return apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' && apercu.refusGlobalLigne === null;
|
||||
}
|
||||
|
||||
// Le refus global d'un refus de decoder : sa cause, le code ; son remède,
|
||||
// le code de details.remede, déjà dans la cause.
|
||||
function globalDeDecoder({ code, details }) {
|
||||
if (!REFUS_DE_DECODER.has(code)) throw new RangeError(`import : refus de decoder inconnu ${JSON.stringify(code)}`);
|
||||
return { cle: code, details, remede: { cle: details.remede, details: {}, dansLaCause: true } };
|
||||
}
|
||||
|
||||
// Le refus global d'un aperçu, null sans refus. Un collage auquel l'aperçu
|
||||
// offre l'ordre de la grille prend le remède qui le nomme.
|
||||
function globalDeLApercu(apercu, collage) {
|
||||
const code = apercu.refusGlobal;
|
||||
if (code === null) return null;
|
||||
const remede = collage && offreLaGrille(apercu) ? REMEDES_DE_LA_GRILLE.get(code) : dans(REMEDES_DE_L_APERCU, code, 'refus global');
|
||||
const rang = apercu.refusGlobalLigne;
|
||||
const details = rang === null ? {} : { ligne: rang };
|
||||
return { cle: code, details, remede: { cle: remede, details, dansLaCause: false } };
|
||||
}
|
||||
|
||||
// Le nom du séparateur, quand la lecture l'a établi : rien sous un caractère
|
||||
// nul, un séparateur introuvable ou un texte blanc, où l'aperçu porte null
|
||||
// sans que le texte ait une seule colonne.
|
||||
function separateurDe(apercu) {
|
||||
const nonEtabli =
|
||||
apercu.refusGlobal === 'CARACTERE_NUL' ||
|
||||
apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' ||
|
||||
apercu.entetes.length === 0;
|
||||
return nonEtabli ? null : dans(SEPARATEURS, apercu.separateur, 'séparateur');
|
||||
}
|
||||
|
||||
// Chaque colonne de l'en-tête : son rang, son en-tête tel qu'écrit, et la
|
||||
// clé de ce qu'elle devient — un champ, une colonne à trancher, une colonne
|
||||
// écartée par le choix d'une autre, une colonne non reconnue.
|
||||
function colonnesDe({ entetes, association }) {
|
||||
const champDuRang = new Map(CHAMPS.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ]));
|
||||
const aTrancher = new Set(association.ambigus.flatMap(({ rangs }) => rangs));
|
||||
const nonReconnues = new Set(association.nonReconnues);
|
||||
return entetes.map((entete, rang) => {
|
||||
let cle = 'import.colonne.ecartee';
|
||||
if (champDuRang.has(rang)) cle = CLES_DES_CHAMPS.get(champDuRang.get(rang));
|
||||
else if (aTrancher.has(rang)) cle = 'import.colonne.aTrancher';
|
||||
else if (nonReconnues.has(rang)) cle = 'import.colonne.nonReconnue';
|
||||
return { rang, entete, cle };
|
||||
});
|
||||
}
|
||||
|
||||
// Chaque ligne valide : son rang, puis une cellule par champ, dans l'ordre
|
||||
// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu ».
|
||||
function lignesDe(lignes) {
|
||||
return lignes.map(({ ligne, champs }) => ({
|
||||
ligne,
|
||||
cellules: CHAMPS.map((champ) => {
|
||||
const valeur = champs[CHAMPS_LUS.get(champ) ?? champ];
|
||||
if (champ === 'exclu') return { champ, texte: null, cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) };
|
||||
return { champ, texte: valeur, cle: null };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les lignes refusées, par l'aperçu ou par la mise à jour, telles que
|
||||
* l'aperçu et le rapport les montrent : le rang, le code, et les détails que
|
||||
* reçoit le motif — la ligne refusée sans son code ni ses champs découpés,
|
||||
* comme exporterRefus les passe au motif du CSV.
|
||||
*
|
||||
* @param {Array<{ligne: number, code: string, brut: string[]}>} refusees
|
||||
* @returns {Array<{ligne: number, code: string, details: Object}>}
|
||||
*/
|
||||
export function refuseesDe(refusees) {
|
||||
return refusees.map(({ code, brut, ...details }) => ({ ligne: details.ligne, code, details }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les personnes que désigne une liste d'identifiants, dans son ordre ; un
|
||||
* identifiant que la liste des participants ne porte plus est omis.
|
||||
*
|
||||
* @param {Array<{id: number, nom: string, prenom: string|null}>} participants
|
||||
* @param {number[]} ids
|
||||
* @returns {Array<{id: number, nom: string, prenom: string|null}>}
|
||||
*/
|
||||
export function nommer(participants, ids) {
|
||||
const parId = new Map(participants.map((participant) => [participant.id, participant]));
|
||||
return ids.filter((id) => parId.has(id)).map((id) => {
|
||||
const { nom, prenom } = parId.get(id);
|
||||
return { id, nom, prenom };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Le mode d'import proposé (§ 10.1) : Ajouter pour un événement sans
|
||||
* participant, Mettre à jour sinon. Remplacer ne se propose jamais : il se
|
||||
* choisit.
|
||||
*
|
||||
* @param {Array<Object>} participants
|
||||
* @returns {'ajouter'|'mettreAJour'}
|
||||
*/
|
||||
export function modePropose(participants) {
|
||||
return participants.length === 0 ? 'ajouter' : 'mettreAJour';
|
||||
}
|
||||
|
||||
// Un modèle vide : rien de lu.
|
||||
const RIEN = Object.freeze({
|
||||
encodage: null,
|
||||
separateur: null,
|
||||
colonnes: [],
|
||||
ambigus: [],
|
||||
valides: 0,
|
||||
lignes: [],
|
||||
refusees: [],
|
||||
fusions: [],
|
||||
doublons: [],
|
||||
homonymes: [],
|
||||
multilignes: [],
|
||||
global: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce que l'aperçu montre, d'un fichier ou d'un collage : encodage (null pour
|
||||
* un collage) et séparateur nommés — « une seule colonne » pour un
|
||||
* séparateur null —, association par colonne, ambiguïtés, lignes valides et
|
||||
* refusées, fusions, doublons, et le refus global, de decoder (refusDecodage,
|
||||
* apercu null) ou de l'aperçu (apercu.refusGlobal), avec la clé de sa cause
|
||||
* et celle de son remède — en clés de libellés et en données.
|
||||
*
|
||||
* La cause est le code du refus, sa propre clé ; ses détails sont ceux de
|
||||
* decoder, ou { ligne } quand l'aperçu nomme un rang. Le remède d'un refus
|
||||
* de decoder est le code de details.remede, que le texte de la cause compose
|
||||
* déjà : remede.dansLaCause le dit, et l'écran ne le répète pas.
|
||||
*
|
||||
* @param {{encodage?: string|null, apercu?: Object|null, refusDecodage?: {code: string, details: Object}|null}} entree
|
||||
* encodage : celui que rend decoder, null pour un collage
|
||||
* @returns {{
|
||||
* encodage: string|null, separateur: string|null,
|
||||
* colonnes: Array<{rang: number, entete: string, cle: string}>,
|
||||
* ambigus: Array<{champ: string, cle: string, rangs: Array<{rang: number, entete: string}>}>,
|
||||
* valides: number,
|
||||
* lignes: Array<{ligne: number, cellules: Array<{champ: string, texte: string|null, cle: string|null}>}>,
|
||||
* refusees: Array<{ligne: number, code: string, details: Object}>,
|
||||
* fusions: Array<{affichee: string, orthographes: Array<{texte: string, lignes: number}>}>,
|
||||
* doublons: Array<{lignes: number[], existants: number[]}>,
|
||||
* homonymes: Array<{ligne: number, participants: number[]}>,
|
||||
* multilignes: Array<{ligne: number, rangs: number[]}>,
|
||||
* global: null|{cle: string, details: Object, remede: {cle: string, details: Object, dansLaCause: boolean}},
|
||||
* }}
|
||||
* encodage, separateur, colonnes[].cle et ambigus[].cle sont des clés de
|
||||
* traduction, comme lignes[].cellules[].cle, celle de la valeur
|
||||
* d'« exclu » ; refusees[].details, ceux que reçoit le motif d'une ligne
|
||||
* (motifLigne) ; fusions[].orthographes[].lignes, le nombre de lignes de
|
||||
* l'orthographe, 0 pour celle que seuls des participants existants portent
|
||||
* @throws {RangeError} un encodage, un séparateur ou un refus sans clé
|
||||
*/
|
||||
export function modeleApercu({ encodage = null, apercu = null, refusDecodage = null }) {
|
||||
if (refusDecodage !== null) return { ...RIEN, global: globalDeDecoder(refusDecodage) };
|
||||
if (apercu === null) return { ...RIEN };
|
||||
const { entetes, association } = apercu;
|
||||
return {
|
||||
encodage: encodage === null ? null : dans(ENCODAGES, encodage, 'encodage'),
|
||||
separateur: separateurDe(apercu),
|
||||
colonnes: colonnesDe(apercu),
|
||||
ambigus: association.ambigus.map(({ champ, rangs }) => ({
|
||||
champ,
|
||||
cle: CLES_DES_CHAMPS.get(champ),
|
||||
rangs: rangs.map((rang) => ({ rang, entete: entetes[rang] })),
|
||||
})),
|
||||
valides: apercu.lignes.length,
|
||||
lignes: lignesDe(apercu.lignes),
|
||||
refusees: refuseesDe(apercu.refusees),
|
||||
fusions: apercu.fusions.map(({ affichee, orthographes }) => ({
|
||||
affichee,
|
||||
orthographes: orthographes.map(({ texte, lignes }) => ({ texte, lignes: lignes.length })),
|
||||
})),
|
||||
doublons: apercu.doublons.map(({ lignes, existants }) => ({ lignes, existants })),
|
||||
homonymes: apercu.homonymes,
|
||||
multilignes: apercu.multilignes,
|
||||
global: globalDeLApercu(apercu, encodage === null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le texte relu dans l'ordre de la grille : précédé d'une ligne d'en-têtes —
|
||||
* les largeur premiers de CHAMPS, puis des en-têtes vides au-delà de sept —,
|
||||
* joints par le séparateur de l'aperçu. Sous AUCUN_ENTETE d'un texte non
|
||||
* blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE
|
||||
* sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1
|
||||
* et la ligne est « nom ». null sous tout autre aperçu. Offert pour un
|
||||
* collage, jamais appliqué seul : le texte rendu repasse par apercevoir, et
|
||||
* les rangs de ce second aperçu comptent la ligne ajoutée.
|
||||
*
|
||||
* @param {string} texte le texte de l'aperçu
|
||||
* @param {Object} apercu l'aperçu de ce texte
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function avecEntetesDeLaGrille(texte, apercu) {
|
||||
if (!offreLaGrille(apercu)) return null;
|
||||
const largeur = apercu.refusGlobal === 'AUCUN_ENTETE' ? apercu.entetes.length : 1;
|
||||
const entetes = Array.from({ length: largeur }, (_, rang) => CHAMPS[rang] ?? '');
|
||||
return `${entetes.join(apercu.separateur ?? '')}\n${texte}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le motif d'une ligne refusée, tel que l'aperçu et le rapport l'affichent :
|
||||
* (code, details) → t(code, details). Le CSV des refus ne le reçoit pas :
|
||||
* son motif est celui de libelle, que lie exporterRefus de
|
||||
* src/application/csv.js.
|
||||
*
|
||||
* @param {(cle: string, details?: Object) => string} t
|
||||
* @returns {(code: string, details: Object) => string}
|
||||
*/
|
||||
export function motifLigne(t) {
|
||||
return (code, details) => t(code, details);
|
||||
}
|
||||
408
src/interface/import/modele.test.js
Normal file
408
src/interface/import/modele.test.js
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : la lecture d'un collage
|
||||
// sans en-tête dans l'ordre de la grille, la clé de la cause et celle du
|
||||
// remède de chaque refus global, l'encodage et le séparateur nommés, et les
|
||||
// titres que « Remplacer » annonce, comptés par la règle unique des titres
|
||||
// pourvus. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { apercevoir, bilanRemplacement, CHAMPS, decoder } from '../../application/csv.js';
|
||||
import { comptesTitres, titresNonPourvus } from '../../application/listes_travail.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import {
|
||||
COLONNES_DES_LIGNES,
|
||||
ENCODAGES,
|
||||
MODES_D_IMPORT,
|
||||
SEPARATEURS,
|
||||
avecEntetesDeLaGrille,
|
||||
modeleApercu,
|
||||
modePropose,
|
||||
motifLigne,
|
||||
nommer,
|
||||
refuseesDe,
|
||||
} from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
const TAB = '\t';
|
||||
const lignes = (...enregistrements) => `${enregistrements.join('\n')}\n`;
|
||||
|
||||
// Les noms lus des lignes valides d'un aperçu, dans l'ordre du texte.
|
||||
const nomsLus = (apercu) => apercu.lignes.map(({ champs }) => champs.nom);
|
||||
|
||||
// Le refus de decoder sur ces octets, sous la forme { code, details }.
|
||||
function refusDeDecoder(octets) {
|
||||
try {
|
||||
decoder(octets);
|
||||
} catch (erreur) {
|
||||
return { code: erreur.code, details: erreur.details };
|
||||
}
|
||||
throw new Error('decoder a accepté les octets');
|
||||
}
|
||||
|
||||
const octets = (...valeurs) => Uint8Array.from(valeurs);
|
||||
const utf8 = (texte) => new TextEncoder().encode(texte);
|
||||
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
|
||||
|
||||
describe('avecEntetesDeLaGrille : un collage sans en-tête lu dans l’ordre de la grille (§ 10.2)', () => {
|
||||
test('trois colonnes à tabulations, sans en-tête : AUCUN_ENTETE, puis nom, prénom et appartenance, les rangs comptant la ligne ajoutée', () => {
|
||||
const texte = lignes(
|
||||
['Ombrelle', 'Iris', 'Club des Merles'].join(TAB),
|
||||
['Pervenche', 'Théo', 'Grisaille'].join(TAB),
|
||||
);
|
||||
const sans = apercevoir(texte);
|
||||
assert.equal(sans.refusGlobal, 'AUCUN_ENTETE');
|
||||
const avec = avecEntetesDeLaGrille(texte, sans);
|
||||
assert.equal(avec, `${['nom', 'prenom', 'appartenance'].join(TAB)}\n${texte}`);
|
||||
const lu = apercevoir(avec);
|
||||
assert.equal(lu.refusGlobal, null);
|
||||
assert.deepEqual(lu.association.colonnes, { nom: 0, prenom: 1, appartenance: 2 });
|
||||
assert.equal(lu.lignes.length, 2);
|
||||
assert.deepEqual(lu.lignes.map(({ ligne }) => ligne), [2, 3]);
|
||||
assert.deepEqual(lu.lignes[0].champs.appartenance, 'Club des Merles');
|
||||
});
|
||||
|
||||
test('deux colonnes : nom et prénom', () => {
|
||||
const texte = lignes(['Ombrelle', 'Iris'].join(TAB), ['Pervenche', 'Théo'].join(TAB));
|
||||
const sans = apercevoir(texte);
|
||||
assert.equal(sans.refusGlobal, 'AUCUN_ENTETE');
|
||||
const lu = apercevoir(avecEntetesDeLaGrille(texte, sans));
|
||||
assert.equal(lu.refusGlobal, null);
|
||||
assert.deepEqual(lu.association.colonnes, { nom: 0, prenom: 1 });
|
||||
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Pervenche']);
|
||||
});
|
||||
|
||||
test('neuf colonnes : les sept champs de la grille, puis deux en-têtes vides, listés non reconnus', () => {
|
||||
const rangee = (nom) => [nom, 'Iris', 'Grisaille', 'iris@exemple.test', 'animation', 'non', 'note', 'x1', 'x2'].join(TAB);
|
||||
const texte = lignes(rangee('Ombrelle'), rangee('Lacasse'));
|
||||
const sans = apercevoir(texte);
|
||||
assert.equal(sans.refusGlobal, 'AUCUN_ENTETE');
|
||||
assert.equal(sans.entetes.length, 9);
|
||||
const avec = avecEntetesDeLaGrille(texte, sans);
|
||||
assert.equal(avec.split('\n')[0], [...CHAMPS, '', ''].join(TAB));
|
||||
const lu = apercevoir(avec);
|
||||
assert.equal(lu.refusGlobal, null);
|
||||
assert.equal(CHAMPS.length, 7);
|
||||
assert.deepEqual(lu.association.colonnes, Object.fromEntries(CHAMPS.map((champ, rang) => [champ, rang])));
|
||||
assert.deepEqual(lu.association.nonReconnues, [7, 8]);
|
||||
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Lacasse']);
|
||||
});
|
||||
|
||||
test('une liste de noms d’une colonne, « Lacasse, Ondine » compris : SEPARATEUR_INTROUVABLE sans rang, puis une colonne, la ligne entière en nom', () => {
|
||||
const texte = lignes('Ombrelle', 'Lacasse, Ondine', 'Pervenche');
|
||||
const sans = apercevoir(texte);
|
||||
assert.equal(sans.refusGlobal, 'SEPARATEUR_INTROUVABLE');
|
||||
assert.equal(sans.refusGlobalLigne, null);
|
||||
const avec = avecEntetesDeLaGrille(texte, sans);
|
||||
assert.equal(avec, `nom\n${texte}`);
|
||||
const lu = apercevoir(avec);
|
||||
assert.equal(lu.refusGlobal, null);
|
||||
assert.equal(lu.separateur, null);
|
||||
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Lacasse, Ondine', 'Pervenche']);
|
||||
});
|
||||
|
||||
test('un texte blanc, un guillemet ouvert, un séparateur écarté à un rang nommé, un aperçu sans refus : null', () => {
|
||||
const blanc = ' \n\t\n';
|
||||
assert.equal(apercevoir(blanc).refusGlobal, 'AUCUN_ENTETE');
|
||||
assert.equal(avecEntetesDeLaGrille(blanc, apercevoir(blanc)), null);
|
||||
|
||||
const guillemet = lignes('nom;prenom', '"Ombrelle;Iris', 'Pervenche;Théo');
|
||||
assert.equal(apercevoir(guillemet).refusGlobal, 'GUILLEMET_OUVERT');
|
||||
assert.equal(avecEntetesDeLaGrille(guillemet, apercevoir(guillemet)), null);
|
||||
|
||||
const ecarte = lignes('Lacasse, Ondine', 'Ombrelle', 'Pervenche');
|
||||
const refus = apercevoir(ecarte);
|
||||
assert.equal(refus.refusGlobal, 'SEPARATEUR_INTROUVABLE');
|
||||
assert.equal(refus.refusGlobalLigne, 2);
|
||||
assert.equal(avecEntetesDeLaGrille(ecarte, refus), null);
|
||||
|
||||
const valide = lignes('nom;prenom', 'Ombrelle;Iris');
|
||||
assert.equal(avecEntetesDeLaGrille(valide, apercevoir(valide)), null);
|
||||
});
|
||||
});
|
||||
|
||||
// Chaque refus global de la table des décisions d'I6, la façon de le
|
||||
// produire, et la clé du remède attendue. Les quatre de decoder passent par
|
||||
// refusDecodage ; les autres, par l'aperçu d'un fichier (encodage donné) ou
|
||||
// d'un collage (encodage null).
|
||||
const REFUS = [
|
||||
['UTF16_SANS_MARQUE', { refusDecodage: refusDeDecoder(octets(0x6e, 0x00, 0x6f, 0x00)) }, 'ENREGISTRER_EN_UTF8'],
|
||||
['UTF16_INVALIDE', { refusDecodage: refusDeDecoder(octets(0xff, 0xfe, 0x6e, 0x00, 0x6f)) }, 'ENREGISTRER_EN_UTF8'],
|
||||
['UTF8_INVALIDE', { refusDecodage: refusDeDecoder(octets(...MARQUE_UTF8, 0x6e, 0xff, 0x6f)) }, 'ENREGISTRER_EN_UTF8'],
|
||||
['CARACTERE_NUL', { refusDecodage: refusDeDecoder(octets(...MARQUE_UTF8, ...utf8('nom\nOmbrelle\u{0}\n'))) }, 'ENREGISTRER_EN_UTF8'],
|
||||
['CARACTERE_NUL', { encodage: null, apercu: apercevoir('nom\nOmbrelle\u{0}\n') }, 'import.remede.caractereNulCollage'],
|
||||
['AUCUN_ENTETE', { encodage: 'utf-8', apercu: apercevoir(lignes('a;b', 'Ombrelle;Iris')) }, 'import.remede.aucunEntete'],
|
||||
['AUCUN_ENTETE', { encodage: null, apercu: apercevoir(lignes('Ombrelle;Iris', 'Pervenche;Théo')) }, 'import.remede.aucunEnteteCollage'],
|
||||
['AUCUN_ENTETE', { encodage: null, apercu: apercevoir(' \n') }, 'import.remede.aucunEntete'],
|
||||
['NOM_NON_ASSOCIE', { encodage: 'utf-8', apercu: apercevoir(lignes('prenom;notes', 'Iris;x')) }, 'import.remede.nomNonAssocie'],
|
||||
['AUCUNE_LIGNE_VALIDE', { encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', ';Iris')) }, 'import.remede.aucuneLigneValide'],
|
||||
['GUILLEMET_OUVERT', { encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', '"Ombrelle;Iris')) }, 'import.remede.guillemetOuvert'],
|
||||
[
|
||||
'SEPARATEUR_INTROUVABLE',
|
||||
{ encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', 'Ombrelle;Iris;x', 'Pervenche')) },
|
||||
'import.remede.separateurIntrouvable',
|
||||
],
|
||||
[
|
||||
'SEPARATEUR_INTROUVABLE',
|
||||
{ encodage: null, apercu: apercevoir(lignes('Ombrelle', 'Lacasse, Ondine')) },
|
||||
'import.remede.separateurIntrouvableCollage',
|
||||
],
|
||||
];
|
||||
|
||||
describe('modeleApercu : la cause et le remède de chaque refus global (§ 10.1)', () => {
|
||||
test('chaque refus de la table des décisions rend la clé de sa cause, le code, et celle de son remède, toutes deux traduites', () => {
|
||||
const codes = new Set(REFUS.map(([code]) => code));
|
||||
assert.equal(codes.size, 9);
|
||||
assert.equal(REFUS.length, 13);
|
||||
for (const [code, entree, remede] of REFUS) {
|
||||
if (entree.apercu !== undefined) assert.equal(entree.apercu.refusGlobal, code, code);
|
||||
if (entree.refusDecodage !== undefined) assert.equal(entree.refusDecodage.code, code, code);
|
||||
const { global } = modeleApercu(entree);
|
||||
assert.notEqual(global, null, code);
|
||||
assert.equal(global.cle, code);
|
||||
assert.equal(global.remede.cle, remede, code);
|
||||
assert.equal(typeof t(global.cle, global.details), 'string');
|
||||
assert.ok(t(global.cle, global.details).length > 0, code);
|
||||
assert.ok(t(global.remede.cle, global.remede.details).length > 0, code);
|
||||
}
|
||||
});
|
||||
|
||||
test('un refus de decoder : son texte compose déjà le remède, que le modèle dit et ne répète pas ; un refus de l’aperçu le porte à part', () => {
|
||||
const [, entree] = REFUS[0];
|
||||
const { global } = modeleApercu(entree);
|
||||
assert.equal(global.remede.dansLaCause, true);
|
||||
assert.ok(t(global.cle, global.details).includes(t(global.remede.cle, global.remede.details)));
|
||||
for (const [code, apercuDonne] of REFUS.slice(4)) {
|
||||
assert.equal(modeleApercu(apercuDonne).global.remede.dansLaCause, false, code);
|
||||
}
|
||||
});
|
||||
|
||||
test('le rang d’un guillemet ouvert et d’un séparateur écarté passe à la cause et au remède', () => {
|
||||
const guillemet = modeleApercu(REFUS[10][1]).global;
|
||||
assert.deepEqual(guillemet.details, { ligne: 2 });
|
||||
assert.match(t(guillemet.cle, guillemet.details), /ligne 2/);
|
||||
assert.match(t(guillemet.remede.cle, guillemet.remede.details), /2/);
|
||||
const separateur = modeleApercu(REFUS[11][1]).global;
|
||||
assert.deepEqual(separateur.details, { ligne: 2 });
|
||||
assert.match(t(separateur.cle, separateur.details), /ligne 2/);
|
||||
});
|
||||
|
||||
test('un code de refus que la table ne connaît pas lève, jamais un remède muet', () => {
|
||||
const apercu = { ...apercevoir(lignes('nom', 'Ombrelle')), refusGlobal: 'REFUS_INVENTE' };
|
||||
assert.throws(() => modeleApercu({ encodage: 'utf-8', apercu }), RangeError);
|
||||
assert.throws(
|
||||
() => modeleApercu({ refusDecodage: { code: 'REFUS_INVENTE', details: { remede: 'ENREGISTRER_EN_UTF8' } } }),
|
||||
RangeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('sous un refus de decoder : ni encodage, ni séparateur, ni colonne, ni ligne', () => {
|
||||
const modele = modeleApercu(REFUS[0][1]);
|
||||
assert.equal(modele.encodage, null);
|
||||
assert.equal(modele.separateur, null);
|
||||
for (const liste of ['colonnes', 'ambigus', 'lignes', 'refusees', 'fusions', 'doublons', 'homonymes', 'multilignes']) {
|
||||
assert.deepEqual(modele[liste], [], liste);
|
||||
}
|
||||
assert.equal(modele.valides, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modeleApercu : ce que l’aperçu montre (§ 10.1)', () => {
|
||||
test('chaque encodage de decoder et chaque séparateur ont leur clé, qui se traduit, et un collage n’a pas d’encodage', () => {
|
||||
const encodages = ['utf-8-bom', 'utf-16le', 'utf-16be', 'utf-8', 'windows-1252'];
|
||||
assert.deepEqual([...ENCODAGES.keys()], encodages);
|
||||
for (const encodage of encodages) assert.ok(t(ENCODAGES.get(encodage)).length > 0, encodage);
|
||||
assert.deepEqual([...SEPARATEURS.keys()], [';', ',', '\t', null]);
|
||||
for (const cle of SEPARATEURS.values()) assert.ok(t(cle).length > 0, cle);
|
||||
assert.equal(t(SEPARATEURS.get(null)), 'une seule colonne');
|
||||
|
||||
const apercu = apercevoir(lignes('nom,prenom', 'Ombrelle,Iris'));
|
||||
const fichier = modeleApercu({ encodage: 'windows-1252', apercu });
|
||||
assert.equal(fichier.encodage, ENCODAGES.get('windows-1252'));
|
||||
assert.equal(fichier.separateur, SEPARATEURS.get(','));
|
||||
const collage = modeleApercu({ apercu });
|
||||
assert.equal(collage.encodage, null);
|
||||
assert.equal(collage.separateur, SEPARATEURS.get(','));
|
||||
const uneColonne = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(lignes('nom', 'Lacasse, Ondine')) });
|
||||
assert.equal(uneColonne.separateur, SEPARATEURS.get(null));
|
||||
});
|
||||
|
||||
test('un séparateur que la lecture n’a pas établi n’est pas nommé « une seule colonne »', () => {
|
||||
for (const [code, entree] of REFUS.slice(4)) {
|
||||
const { separateur } = modeleApercu(entree);
|
||||
const etabli = ['AUCUN_ENTETE', 'NOM_NON_ASSOCIE', 'AUCUNE_LIGNE_VALIDE', 'GUILLEMET_OUVERT'].includes(code) &&
|
||||
entree.apercu.entetes.length > 0;
|
||||
if (etabli) assert.equal(separateur, SEPARATEURS.get(entree.apercu.separateur), code);
|
||||
else assert.equal(separateur, null, code);
|
||||
}
|
||||
});
|
||||
|
||||
test('colonnes associées, non reconnues, à trancher puis écartée ; ambiguïtés nommées par leurs en-têtes', () => {
|
||||
const texte = lignes('Nom;Organisation;Entreprise;Allergies', 'Ombrelle;Club des Merles;Grisaille;aucune');
|
||||
const ambigu = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
|
||||
assert.deepEqual(
|
||||
ambigu.colonnes.map(({ rang, entete, cle }) => [rang, entete, cle]),
|
||||
[
|
||||
[0, 'Nom', 'import.champ.nom'],
|
||||
[1, 'Organisation', 'import.colonne.aTrancher'],
|
||||
[2, 'Entreprise', 'import.colonne.aTrancher'],
|
||||
[3, 'Allergies', 'import.colonne.nonReconnue'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(ambigu.ambigus, [
|
||||
{ champ: 'appartenance', cle: 'import.champ.appartenance', rangs: [{ rang: 1, entete: 'Organisation' }, { rang: 2, entete: 'Entreprise' }] },
|
||||
]);
|
||||
const tranche = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte, { choix: { appartenance: 2 } }) });
|
||||
assert.deepEqual(tranche.colonnes.map(({ cle }) => cle), [
|
||||
'import.champ.nom', 'import.colonne.ecartee', 'import.champ.appartenance', 'import.colonne.nonReconnue',
|
||||
]);
|
||||
assert.deepEqual(tranche.ambigus, []);
|
||||
for (const { cle } of [...ambigu.colonnes, ...tranche.colonnes]) assert.ok(t(cle).length > 0, cle);
|
||||
});
|
||||
|
||||
test('chaque champ de CHAMPS a sa clé, qui se traduit', () => {
|
||||
const texte = lignes(CHAMPS.join(';'), ['Ombrelle', 'Iris', 'Grisaille', 'i@exemple.test', 'x', 'non', 'n'].join(';'));
|
||||
const { colonnes } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
|
||||
assert.equal(colonnes.length, 7);
|
||||
for (const { cle } of colonnes) {
|
||||
assert.match(cle, /^import\.champ\./);
|
||||
assert.ok(t(cle).length > 0, cle);
|
||||
}
|
||||
});
|
||||
|
||||
test('lignes refusées, fusions et doublons, en données ; le motif affiché est celui de t', () => {
|
||||
const existants = [{ id: 4, nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Coopérative Bleue' }];
|
||||
const texte = lignes(
|
||||
'nom;prenom;appartenance;exclu',
|
||||
'Ombrelle;Iris;cooperative bleue;',
|
||||
';Théo;Grisaille;',
|
||||
'Pervenche;Théo;COOPÉRATIVE BLEUE;peut-être',
|
||||
'Pervenche;Théo;COOPÉRATIVE BLEUE;x',
|
||||
'Lacasse;Ondine;;',
|
||||
);
|
||||
const modele = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte, { participants: existants }) });
|
||||
assert.equal(modele.valides, 3);
|
||||
assert.deepEqual(modele.refusees, [
|
||||
{ ligne: 3, code: 'NOM_ABSENT', details: { ligne: 3, valeur: null } },
|
||||
{ ligne: 4, code: 'EXCLU_INCONNU', details: { ligne: 4, valeur: 'peut-être' } },
|
||||
]);
|
||||
assert.deepEqual(modele.fusions, [
|
||||
{
|
||||
affichee: 'Coopérative Bleue',
|
||||
orthographes: [
|
||||
{ texte: 'Coopérative Bleue', lignes: 0 },
|
||||
{ texte: 'cooperative bleue', lignes: 1 },
|
||||
{ texte: 'COOPÉRATIVE BLEUE', lignes: 1 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(modele.doublons, [{ lignes: [2], existants: [4] }]);
|
||||
const motif = motifLigne(t);
|
||||
const [, refusee] = modele.refusees;
|
||||
assert.equal(motif(refusee.code, refusee.details), t('EXCLU_INCONNU', { ligne: 4, valeur: 'peut-être' }));
|
||||
assert.match(motif(refusee.code, refusee.details), /peut-être/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les lignes, les refus de la mise à jour, les personnes désignées, le mode proposé (§ 10.1)', () => {
|
||||
test('chaque ligne valide montre ses sept champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur', () => {
|
||||
const texte = lignes(
|
||||
'notes;exclu;nom;prenom',
|
||||
'arrive tard;Oui;Ombrelle;Iris',
|
||||
';;Pervenche;',
|
||||
';0;Lacasse;Ondine',
|
||||
);
|
||||
const { lignes: lues } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
|
||||
assert.equal(lues.length, 3);
|
||||
assert.deepEqual(lues.map(({ ligne }) => ligne), [2, 3, 4]);
|
||||
for (const { cellules } of lues) assert.deepEqual(cellules.map(({ champ }) => champ), [...CHAMPS]);
|
||||
assert.deepEqual(COLONNES_DES_LIGNES.map(({ champ }) => champ), [...CHAMPS]);
|
||||
for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle);
|
||||
const [premiere, deuxieme, troisieme] = lues;
|
||||
assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard']);
|
||||
assert.equal(premiere.cellules[5].cle, 'import.exclu.oui');
|
||||
assert.equal(deuxieme.cellules[5].cle, null);
|
||||
assert.equal(troisieme.cellules[5].cle, 'import.exclu.non');
|
||||
for (const cle of ['import.exclu.oui', 'import.exclu.non']) assert.ok(t(cle).length > 0);
|
||||
});
|
||||
|
||||
test('refuseesDe : les refus de la mise à jour gardent les personnes désignées et la première ligne, sans les champs découpés', () => {
|
||||
const refusees = [
|
||||
{ ligne: 3, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: [2, 5], brut: ['Lacasse', ''] },
|
||||
{ ligne: 5, code: 'DEJA_DESIGNE', valeur: null, participants: [1], premiereLigne: 4, brut: ['Ombrelle', ''] },
|
||||
];
|
||||
assert.deepEqual(refuseesDe(refusees), [
|
||||
{ ligne: 3, code: 'PLUSIEURS_CORRESPONDENT', details: { ligne: 3, valeur: null, participants: [2, 5] } },
|
||||
{ ligne: 5, code: 'DEJA_DESIGNE', details: { ligne: 5, valeur: null, participants: [1], premiereLigne: 4 } },
|
||||
]);
|
||||
const motif = motifLigne(t);
|
||||
assert.match(motif('DEJA_DESIGNE', refuseesDe(refusees)[1].details), /ligne 4/);
|
||||
});
|
||||
|
||||
test('nommer : les personnes d’une liste d’identifiants, dans son ordre ; une personne que la liste n’a plus est omise', () => {
|
||||
const participants = [
|
||||
{ id: 2, nom: 'Lacasse', prenom: 'Ondine' },
|
||||
{ id: 5, nom: 'Lacasse', prenom: null },
|
||||
];
|
||||
assert.deepEqual(nommer(participants, [5, 2, 9]), [
|
||||
{ id: 5, nom: 'Lacasse', prenom: null },
|
||||
{ id: 2, nom: 'Lacasse', prenom: 'Ondine' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('les trois modes offerts, chacun nommé par sa clé, ceux de l’aperçu de src/csv', () => {
|
||||
assert.deepEqual(MODES_D_IMPORT.map(({ mode }) => mode), ['ajouter', 'mettreAJour', 'remplacer']);
|
||||
for (const { mode, cle } of MODES_D_IMPORT) {
|
||||
assert.ok(t(cle).length > 0, cle);
|
||||
assert.equal(apercevoir(lignes('nom', 'Ombrelle'), { mode }).mode, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('le mode proposé : Ajouter pour une liste vide, Mettre à jour sinon ; jamais Remplacer', () => {
|
||||
assert.equal(modePropose([]), 'ajouter');
|
||||
assert.equal(modePropose([{ id: 1, nom: 'Ombrelle' }]), 'mettreAJour');
|
||||
});
|
||||
});
|
||||
|
||||
// Une charge de deux tours, une table de six places, trois titres à cette
|
||||
// table, et trois personnes : la première réservée « tous » au siège du
|
||||
// premier titre, la deuxième réservée au tour 1 seul au siège du deuxième,
|
||||
// la troisième sans réservation. Sans attribution, les réservations ne
|
||||
// portent pas de siège.
|
||||
function chargeAuxTitres({ attribuerSieges }) {
|
||||
const charge = creerCharge({ id: 'epreuve', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 2 });
|
||||
charge.reglages.attribuerSieges = attribuerSieges;
|
||||
charge.participants = ['Ombrelle', 'Pervenche', 'Lacasse'].map((nom, rang) => ({
|
||||
id: rang + 1, nom, prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false,
|
||||
}));
|
||||
charge.tables = [{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }];
|
||||
charge.titres = [1, 2, 3].map((siege) => ({ table: 1, siege, libelle: `Animation ${siege}` }));
|
||||
charge.reservations = [
|
||||
{ participant: 1, table: 1, siege: attribuerSieges ? 1 : null, portee: 'tous', tour: null },
|
||||
{ participant: 2, table: 1, siege: attribuerSieges ? 2 : null, portee: 'tour', tour: 1 },
|
||||
];
|
||||
charge.prochainsIds = { participant: 4, table: 2, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
describe('les titres que « Remplacer » annonce (§ 10.1, § 4.1)', () => {
|
||||
for (const attribuerSieges of [true, false]) {
|
||||
test(`sièges ${attribuerSieges ? 'attribués' : 'non attribués, réservations sans siège'} : deux titres perdent leur réservation, un « tous » et un « tour »`, () => {
|
||||
const charge = chargeAuxTitres({ attribuerSieges });
|
||||
const bilan = bilanRemplacement(charge);
|
||||
assert.equal(bilan.titresPourvus, 2);
|
||||
assert.equal(charge.tables.length, 1);
|
||||
const pourSoiree = charge.tables.reduce((somme, { id }) => somme + comptesTitres(charge, id).pourvus, 0);
|
||||
assert.equal(pourSoiree, 1);
|
||||
const aUnTour = titresNonPourvus(charge).filter(({ pourvuAuxTours }) => pourvuAuxTours.length > 0).length;
|
||||
assert.equal(aUnTour, 1);
|
||||
assert.equal(bilan.titresPourvus, pourSoiree + aUnTour);
|
||||
assert.deepEqual(bilan, { participants: 3, reservations: 2, titresPourvus: 2, propositions: 0 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -3,5 +3,115 @@
|
|||
|
||||
// Les libellés de l'accueil : la liste des événements, les démonstrations, les
|
||||
// opérations de fichiers, la corbeille, le dossier de travail et la
|
||||
// récupération (§ 14.6). Chaque clé commence par « accueil. ».
|
||||
export const TABLE = new Map();
|
||||
// récupération (§ 14.6). Chaque clé commence par « accueil. ». Les codes que
|
||||
// l'accueil affiche — avis et questions du dossier de travail, refus, état
|
||||
// illisible — gardent leur texte dans les tables de l'application.
|
||||
|
||||
// Une donnée — un chemin, une base — n'entre dans aucun libellé d'écran :
|
||||
// le composant la rend dans son propre nœud, à côté du texte traduit
|
||||
// (§ 14.6). Un compte s'accorde dans la phrase qui le porte.
|
||||
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- La liste (§ 8.1) ----------------------------------------------------
|
||||
// Les intitulés des informations d'une ligne ; le nom est la commande qui
|
||||
// ouvre l'événement.
|
||||
['accueil.colonne.date', 'Date'],
|
||||
['accueil.colonne.etat', 'État'],
|
||||
['accueil.colonne.participants', 'Participants'],
|
||||
['accueil.colonne.modification', 'Dernière modification'],
|
||||
['accueil.colonne.chemin', 'Fichier'],
|
||||
|
||||
// Le nom d'un état du plan (§ 9), que rend cleDEtat (modele.js).
|
||||
['accueil.etat.brouillon', 'Brouillon'],
|
||||
['accueil.etat.propose', 'Proposé'],
|
||||
['accueil.etat.retenu', 'Retenu'],
|
||||
['accueil.etat.bloque', 'Bloqué'],
|
||||
|
||||
// autres : le nombre des autres bases qui portent le même identifiant
|
||||
// (§ 8.7, § 8.8) ; elles suivent le texte, chacune dans son propre nœud.
|
||||
[
|
||||
'accueil.signalement.doublon',
|
||||
({ autres }) =>
|
||||
autres <= 1
|
||||
? 'Le même événement se trouve aussi dans le fichier nommé ci-dessous : deux fichiers portent son ' +
|
||||
'identifiant, comme après un renommage interrompu. Gardez-en un, et supprimez l\u{2019}autre.'
|
||||
: `Le même événement se trouve aussi dans les ${autres} fichiers nommés ci-dessous : chacun porte son ` +
|
||||
'identifiant, comme après un renommage interrompu. Gardez-en un, et supprimez les autres.',
|
||||
],
|
||||
|
||||
// --- La création (§ 2.1, étape 1) ----------------------------------------
|
||||
['accueil.creation.titre', 'Créer un événement'],
|
||||
['accueil.creation.nom', 'Nom de l\u{2019}événement'],
|
||||
['accueil.creation.date', 'Date, facultative (AAAA-MM-JJ)'],
|
||||
['accueil.creation.creer', 'Créer l\u{2019}événement'],
|
||||
|
||||
// --- Les opérations de fichiers (§ 8.7) ----------------------------------
|
||||
['accueil.operation.importer', 'Importer un événement depuis un fichier\u{2026}'],
|
||||
['accueil.operation.copier', 'Copier'],
|
||||
['accueil.operation.renommer', 'Renommer'],
|
||||
['accueil.operation.supprimer', 'Supprimer'],
|
||||
['accueil.operation.confirmerSuppression', 'Confirmer : déplacer dans la corbeille'],
|
||||
['accueil.operation.nomCopie', 'Nom de la copie'],
|
||||
['accueil.operation.nouveauNom', 'Nouveau nom'],
|
||||
['accueil.operation.validerCopie', 'Créer la copie'],
|
||||
['accueil.operation.validerRenommage', 'Renommer l\u{2019}événement'],
|
||||
['accueil.operation.annuler', 'Annuler'],
|
||||
|
||||
// --- Les démonstrations (§ 15) ---------------------------------------------
|
||||
['accueil.demonstrations.titre', 'Démonstrations'],
|
||||
[
|
||||
'accueil.demonstrations.explication',
|
||||
'Chacune se charge comme un nouvel événement, numéroté et ouvert en lecture ; ' +
|
||||
'aucun autre événement n\u{2019}est touché.',
|
||||
],
|
||||
|
||||
// --- La corbeille (§ 8.7) --------------------------------------------------
|
||||
['accueil.corbeille.titre', 'Corbeille'],
|
||||
[
|
||||
'accueil.corbeille.explication',
|
||||
'Un événement supprimé y est déplacé avec tous ses fichiers, dans un dossier daté. ' +
|
||||
'Le logiciel ne la vide jamais.',
|
||||
],
|
||||
|
||||
// --- Le dossier de travail (§ 8.6) -----------------------------------------
|
||||
['accueil.dossier.titre', 'Dossier de travail'],
|
||||
['accueil.dossier.explorateur', 'Ouvrir dans l\u{2019}explorateur'],
|
||||
['accueil.question.reessayer', 'Réessayer'],
|
||||
['accueil.question.documents', 'Travailler dans les Documents, en y copiant ces événements'],
|
||||
['accueil.question.continuer', 'Continuer : ouvrir la liste, aucun geste ne s\u{2019}y enregistrera'],
|
||||
// evenements : ce que porte l'autre emplacement, dont le chemin suit le
|
||||
// texte, dans son propre nœud.
|
||||
[
|
||||
'accueil.autre.proposition',
|
||||
({ evenements }) =>
|
||||
`Le dossier de travail est neuf, et cet autre dossier porte ${compte(evenements, 'événement', 'événements')} :`,
|
||||
],
|
||||
[
|
||||
'accueil.autre.originaux',
|
||||
'L\u{2019}import les copie : les originaux restent où ils sont, et chaque copie vit ensuite sa propre vie.',
|
||||
],
|
||||
['accueil.autre.importer', 'Importer ces événements'],
|
||||
// importes, refuses : les comptes de l'import de l'autre emplacement.
|
||||
[
|
||||
'accueil.autre.resultat',
|
||||
({ importes, refuses }) =>
|
||||
refuses === 0
|
||||
? `${compte(importes, 'événement importé', 'événements importés')}.`
|
||||
: `${compte(importes, 'événement importé', 'événements importés')} ; ` +
|
||||
`${compte(refuses, 'ne s\u{2019}est pas importé', 'ne se sont pas importés')}.`,
|
||||
],
|
||||
|
||||
// --- La récupération (§ 8.8) -----------------------------------------------
|
||||
['accueil.recuperation.titre', 'Récupération d\u{2019}un événement'],
|
||||
['accueil.recuperation.fichier', 'Fichier'],
|
||||
[
|
||||
'accueil.recuperation.offerte',
|
||||
'La version choisie s\u{2019}ouvre en lecture : rien ne s\u{2019}écrit avant un geste en écriture.',
|
||||
],
|
||||
[
|
||||
'accueil.recuperation.aucunSecours',
|
||||
'Aucune version de secours ne se lit : l\u{2019}événement ne s\u{2019}ouvre pas, et rien n\u{2019}est créé à sa place.',
|
||||
],
|
||||
['accueil.recuperation.retour', 'Revenir à la liste'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3,4 +3,85 @@
|
|||
|
||||
// Les libellés communs aux écrans : commandes, confirmations, remèdes d'un
|
||||
// refus (§ 14.6). Chaque clé commence par « communs. ».
|
||||
export const TABLE = new Map();
|
||||
//
|
||||
// communs.commande.* nomme les commandes de mode et de secours, que la place
|
||||
// fixe de l'événement et les écrans de la liste reprennent. communs.remede.*
|
||||
// nomme le remède d'un refus : communs.remede.<commande> pour chaque commande
|
||||
// du registre (src/application/commandes.js), communs.remede.<geste> pour un
|
||||
// geste du contrat, suivi de .<variante> quand ses arguments changent son
|
||||
// libellé (primitives/commande.js, libelleRemede). Un remède qui fait ce que
|
||||
// fait une commande de mode en reprend le texte, par texte.
|
||||
|
||||
// Le libellé d'une autre clé de cette table.
|
||||
const meme = (cle) => (details, texte) => texte(cle);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Les commandes de mode et de secours (§ 8.4, § 8.5, § 8.6, § 8.8) -----
|
||||
['communs.commande.modifier', 'Modifier'],
|
||||
['communs.commande.debloquer', 'Débloquer'],
|
||||
['communs.commande.confirmerDeblocage', 'Confirmer le déblocage'],
|
||||
['communs.commande.lecture', 'Revenir en lecture'],
|
||||
['communs.commande.ecrireDocuments', 'Écrire dans les Documents'],
|
||||
['communs.commande.choisirDossier', 'Choisir un dossier\u{2026}'],
|
||||
['communs.commande.ouvrirPrecedent', 'Ouvrir la version précédente'],
|
||||
['communs.commande.ouvrirJournal', 'Ouvrir le dernier instant du journal'],
|
||||
|
||||
// --- La fenêtre à confirmer (§ 2.1) -------------------------------------------
|
||||
['communs.confirmation.confirmer', 'Confirmer'],
|
||||
['communs.confirmation.annuler', 'Annuler'],
|
||||
['communs.confirmation.enCours', 'Opération en cours\u{2026}'],
|
||||
|
||||
// --- Les remèdes des gestes du contrat ------------------------------------
|
||||
['communs.remede.modifier', meme('communs.commande.modifier')],
|
||||
['communs.remede.debloquer', meme('communs.commande.debloquer')],
|
||||
['communs.remede.reprendreVerrou', 'Reprendre le verrou'],
|
||||
['communs.remede.afficherTour', ({ tour }) => `Afficher le tour ${tour}`],
|
||||
['communs.remede.choisirPlace', 'Choisir une place'],
|
||||
['communs.remede.ouvrirSecours.precedent', meme('communs.commande.ouvrirPrecedent')],
|
||||
['communs.remede.ouvrirSecours.journal', meme('communs.commande.ouvrirJournal')],
|
||||
['communs.remede.ecrireAilleurs.documents', meme('communs.commande.ecrireDocuments')],
|
||||
['communs.remede.ecrireAilleurs.choisi', meme('communs.commande.choisirDossier')],
|
||||
['communs.remede.ouvrirSection.import', "Ouvrir l'import"],
|
||||
['communs.remede.ouvrirSection.participants', 'Ouvrir les participants'],
|
||||
['communs.remede.ouvrirSection.tables', 'Ouvrir les tables'],
|
||||
['communs.remede.ouvrirSection.plan', 'Ouvrir le plan'],
|
||||
['communs.remede.ouvrirSection.diagnostic', 'Ouvrir le diagnostic'],
|
||||
['communs.remede.ouvrirSection.generation', 'Ouvrir la génération'],
|
||||
['communs.remede.ouvrirSection.propositions', 'Ouvrir les propositions'],
|
||||
|
||||
// --- Les remèdes de commande : un par commande du registre ------------------
|
||||
// debloquer, commande et geste, porte la clé des gestes, plus haut.
|
||||
['communs.remede.ajouterParticipant', 'Ajouter la personne'],
|
||||
['communs.remede.modifierParticipant', 'Modifier la personne'],
|
||||
['communs.remede.exclureParticipant', 'Exclure la personne'],
|
||||
['communs.remede.reintegrerParticipant', 'Réintégrer la personne'],
|
||||
['communs.remede.supprimerParticipant', 'Supprimer la personne'],
|
||||
['communs.remede.importerParticipants', 'Importer la liste'],
|
||||
['communs.remede.changerEtat', "Changer l'état du plan"],
|
||||
['communs.remede.enregistrerGeneration', 'Enregistrer la génération'],
|
||||
['communs.remede.retenirProposition', 'Retenir la proposition'],
|
||||
['communs.remede.effacerPropositions', 'Effacer les propositions'],
|
||||
['communs.remede.creerTable', 'Créer une table'],
|
||||
['communs.remede.supprimerTable', 'Supprimer la table'],
|
||||
['communs.remede.deplacerTable', 'Déplacer la table'],
|
||||
// sieges : le nombre de places que le remède donne à la table, quand il le
|
||||
// fixe (SURRESERVATION, § 5.9).
|
||||
[
|
||||
'communs.remede.changerPlacesTable',
|
||||
({ sieges }) => (Number.isInteger(sieges) ? `Passer la table à ${sieges} places` : 'Changer le nombre de places'),
|
||||
],
|
||||
['communs.remede.realignerTable', 'Réaligner la table'],
|
||||
['communs.remede.changerDefaut', 'Changer le nombre de places par défaut'],
|
||||
['communs.remede.changerTours', 'Changer le nombre de tours'],
|
||||
['communs.remede.modifierReglage', 'Modifier le réglage'],
|
||||
['communs.remede.reserver', 'Réserver la place'],
|
||||
['communs.remede.retirerReservation', 'Retirer la réservation'],
|
||||
['communs.remede.deplacerReservation', 'Déplacer la réservation'],
|
||||
['communs.remede.poserTitre', 'Poser le titre'],
|
||||
['communs.remede.retirerTitre', 'Retirer le titre'],
|
||||
['communs.remede.designer', 'Désigner la personne'],
|
||||
['communs.remede.convertirEnTous', 'Réserver pour tous les tours'],
|
||||
['communs.remede.deplacerDansRetenu', 'Déplacer dans le placement retenu'],
|
||||
['communs.remede.echangerDansRetenu', 'Échanger les deux places'],
|
||||
['communs.remede.retirerDuTour', 'Retirer du tour'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les textes de la coquille (§ 18.6) : le nom de l'application, le titre du
|
||||
// bandeau et de la fenêtre, et la provenance de la construction par mode de
|
||||
// Vite. Un mode sans clé s'affiche sous son propre nom (libelles.js).
|
||||
// Les textes de la coquille (§ 8.4, § 18.6, § 19.2) : le nom de
|
||||
// l'application, le titre du bandeau et de la fenêtre, la provenance de la
|
||||
// construction par mode de Vite, le libellé de mode, l'annonce de format, le
|
||||
// réglage de thème, « Liste des événements », le panneau « À propos » et son
|
||||
// bloc, et le titre de chaque section de l'événement, que la navigation et
|
||||
// chaque section reprennent. Un mode sans clé s'affiche sous son propre nom
|
||||
// (libelles.js). Chaque clé commence par « coquille. ».
|
||||
export const TABLE = new Map([
|
||||
['coquille.nom', 'Gestion table tournante Libre'],
|
||||
['coquille.titre', ({ version }, texte) => `${texte('coquille.nom')} \u{2014} ${version}`],
|
||||
|
|
@ -11,4 +15,60 @@ export const TABLE = new Map([
|
|||
['coquille.provenance.livraison', 'livraison'],
|
||||
['coquille.provenance.documentation', 'documentation'],
|
||||
['coquille.provenance.test', 'épreuve'],
|
||||
|
||||
// La version à l'écran : le mot qui la précède au premier écran, et la
|
||||
// marque qui la remplace dans une construction de documentation (§ 19.4).
|
||||
['coquille.version.libelle', 'version'],
|
||||
['coquille.version.documentation', 'construction de documentation'],
|
||||
|
||||
// Le libellé de mode, que rend cleDeMode (mode.js).
|
||||
['coquille.mode.lecture', 'Lecture'],
|
||||
['coquille.mode.ecriture', 'Écriture'],
|
||||
['coquille.mode.bloque', 'Plan bloqué'],
|
||||
|
||||
// L'annonce de la version de format du fichier ouvert (§ 18.6).
|
||||
['coquille.format.plusRecent', 'Ce fichier s\u{2019}ouvre en lecture seule'],
|
||||
['coquille.format.plusAncien', 'Ce fichier se convertira à votre première modification'],
|
||||
|
||||
// Le réglage de thème (§ 19.2).
|
||||
['coquille.theme.titre', 'Thème'],
|
||||
['coquille.theme.clair', 'Clair'],
|
||||
['coquille.theme.sombre', 'Sombre'],
|
||||
|
||||
['coquille.liste', 'Liste des événements'],
|
||||
|
||||
// Le panneau « À propos ». Le bloc : nom, version affichée, version
|
||||
// Windows, plateforme, provenance — son libellé, ou le mode lui-même quand
|
||||
// il n'en a pas —, version de format.
|
||||
['coquille.apropos.titre', 'À propos'],
|
||||
[
|
||||
'coquille.apropos.bloc',
|
||||
({ affichee, windows, plateforme, provenance, format }, texte) => {
|
||||
const cle = `coquille.provenance.${provenance}`;
|
||||
return [
|
||||
texte('coquille.nom'),
|
||||
affichee,
|
||||
windows,
|
||||
plateforme,
|
||||
texte.existe(cle) ? texte(cle) : provenance,
|
||||
texte('coquille.apropos.format', { format }),
|
||||
].join(' \u{00B7} ');
|
||||
},
|
||||
],
|
||||
['coquille.apropos.format', ({ format }) => `format ${format}`],
|
||||
['coquille.apropos.copier', 'Copier'],
|
||||
['coquille.apropos.fermer', 'Fermer'],
|
||||
['coquille.apropos.copie', 'Bloc copié dans le presse-papiers'],
|
||||
['coquille.apropos.copieRefusee', 'Le presse-papiers a refusé la copie'],
|
||||
|
||||
// Les sections de l'événement, dans l'ordre du § 2.1 (sections.js), et
|
||||
// leur navigation.
|
||||
['coquille.navigation', 'Sections de l\u{2019}événement'],
|
||||
['coquille.section.import', 'Import'],
|
||||
['coquille.section.participants', 'Participants'],
|
||||
['coquille.section.tables', 'Tables et tours'],
|
||||
['coquille.section.plan', 'Plan'],
|
||||
['coquille.section.diagnostic', 'Diagnostic'],
|
||||
['coquille.section.generation', 'Génération'],
|
||||
['coquille.section.propositions', 'Propositions'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,126 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés du diagnostic d'une configuration (§ 14.6). Chaque clé commence
|
||||
// par « diagnostic. ».
|
||||
export const TABLE = new Map();
|
||||
// Les libellés du diagnostic d'une configuration (§ 5.6, § 14.6). Chaque clé
|
||||
// commence par « diagnostic. ». Un énoncé (phrases.js) a son texte sous sa
|
||||
// clé, son remède sous diagnostic.remede.<nom>. Chaque chiffre nomme son
|
||||
// unité : une collision se compte en occurrences, une paire distincte à
|
||||
// part (§ 5.4). L'appartenance d'un groupe est une donnée : l'écran la pose
|
||||
// dans son propre nœud, avant le texte, qu'aucune entrée de cette table ne
|
||||
// lui fait porter.
|
||||
import { compte, enumeration } from '../../application/libelles/aides.js';
|
||||
|
||||
// « sur le tour » ou « sur les R tours ».
|
||||
const surLesTours = (tours) => (tours === 1 ? 'sur le tour' : `sur les ${tours} tours`);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Le cadre de l'écran ---------------------------------------------------
|
||||
['diagnostic.enonces', 'Ce que la configuration impose'],
|
||||
['diagnostic.nonCalcule', 'La configuration ne se normalise pas : le diagnostic ne se calcule pas.'],
|
||||
|
||||
// --- La séparation vidée de son sens (§ 7.5, § 15.4) -----------------------
|
||||
[
|
||||
'diagnostic.uneSeuleAppartenance',
|
||||
'une seule appartenance, portée par chaque personne, sous séparation active : la séparation est vide de sens',
|
||||
],
|
||||
['diagnostic.collisionPartout', 'tout placement porte une collision à chaque table, à chaque tour'],
|
||||
|
||||
// --- Les collisions et l'excédent (§ 5.4, § 5.6) ---------------------------
|
||||
[
|
||||
'diagnostic.collisions',
|
||||
({ effectif, plancherCumulees, plancherPairesDistinctes, tours }) =>
|
||||
`${compte(effectif, 'personne', 'personnes')} : au moins ` +
|
||||
`${compte(plancherCumulees, 'collision cumulée', 'collisions cumulées')} ${surLesTours(tours)}, ` +
|
||||
`${plancherCumulees === 1 ? 'portée' : 'portées'} par au moins ` +
|
||||
compte(plancherPairesDistinctes, 'paire distincte', 'paires distinctes'),
|
||||
],
|
||||
[
|
||||
'diagnostic.excedent',
|
||||
({ plancherExcedent }) =>
|
||||
`excédent d'au moins ${compte(plancherExcedent, 'collision', 'collisions')} : des retrouvailles sont forcées`,
|
||||
],
|
||||
|
||||
// --- La redondance et les animateurs (§ 5.6) -------------------------------
|
||||
[
|
||||
'diagnostic.redondance',
|
||||
({ redondanceMinimale }) =>
|
||||
`au moins ${compte(redondanceMinimale, 'appartenance recroisée', 'appartenances recroisées')} par personne`,
|
||||
],
|
||||
[
|
||||
'diagnostic.animateurs',
|
||||
({ redondanceImposeeParAnimateurs }) =>
|
||||
'tous les animateurs partagent une appartenance : chaque mobile accumule au moins ' +
|
||||
`${compte(redondanceImposeeParAnimateurs, 'redondance', 'redondances')} qu'aucune recherche ne réduit`,
|
||||
],
|
||||
|
||||
// --- L'ancrage par table (§ 5.6, § 5.9) ------------------------------------
|
||||
['diagnostic.ancrage.gelee', ({ numero, capacite }) => `la table ${numero} ne tourne plus : ses ${capacite} places sont ancrées`],
|
||||
[
|
||||
'diagnostic.ancrage.moitie',
|
||||
({ numero, ancres, capacite }) =>
|
||||
`la table ${numero} a ${compte(ancres, 'place ancrée', 'places ancrées')} sur ${capacite} : ` +
|
||||
"elle réduit d'autant le brassage de tout l'événement",
|
||||
],
|
||||
|
||||
// --- L'écart d'itinéraire, le manque de places (§ 5.6, § 12.10.4) ----------
|
||||
[
|
||||
'diagnostic.ecartItineraire',
|
||||
({ plancher, mobilesAuMoins }) =>
|
||||
`écart d'itinéraire maximal : plancher ${plancher}, au moins ` +
|
||||
(mobilesAuMoins === 1 ? '1 mobile finit sous son plafond a priori' : `${mobilesAuMoins} mobiles finissent sous leur plafond a priori`),
|
||||
],
|
||||
[
|
||||
'diagnostic.placesManquantes',
|
||||
({ placesManquantes }) =>
|
||||
placesManquantes === 1 ? '1 place manque à chaque tour' : `${placesManquantes} places manquent à chaque tour`,
|
||||
],
|
||||
|
||||
// --- Le plafond a priori et les imposés (§ 5.4, § 5.5) ---------------------
|
||||
[
|
||||
'diagnostic.plafond',
|
||||
({ valeur, ancres, mobiles }) =>
|
||||
`plafond a priori ${compte(valeur, 'rencontre', 'rencontres')} pour ` +
|
||||
enumeration([
|
||||
...(ancres > 0 ? [compte(ancres, 'ancré', 'ancrés')] : []),
|
||||
...(mobiles > 0 ? [compte(mobiles, 'mobile', 'mobiles')] : []),
|
||||
]),
|
||||
],
|
||||
['diagnostic.plafondInconnu', (_, texte) => `plafond a priori : ${texte('format.vide')}`],
|
||||
[
|
||||
'diagnostic.imposes',
|
||||
({ retoursImposes, ancragesImposantRetours }) =>
|
||||
`${compte(retoursImposes, 'retour imposé', 'retours imposés')} par ${compte(ancragesImposantRetours, 'ancrage', 'ancrages')}`,
|
||||
],
|
||||
|
||||
// --- Les remèdes -----------------------------------------------------------
|
||||
['diagnostic.remede.separation', 'désactiver la séparation des appartenances'],
|
||||
[
|
||||
'diagnostic.remede.tables',
|
||||
({ tablesManquantes }) =>
|
||||
tablesManquantes === 1
|
||||
? '1 table de plus, de la plus grande capacité présente, la rend évitable'
|
||||
: `${tablesManquantes} tables de plus, de la plus grande capacité présente, la rendent évitable`,
|
||||
],
|
||||
[
|
||||
'diagnostic.remede.redistribuer',
|
||||
"les réservations assoient des membres ensemble : répartir autrement l'appartenance la rend évitable",
|
||||
],
|
||||
[
|
||||
'diagnostic.remede.tours',
|
||||
({ toursSansRedondance, tours }) =>
|
||||
`avec ${compte(toursSansRedondance, 'tour', 'tours')} au lieu de ${tours}, aucune ne le serait`,
|
||||
],
|
||||
['diagnostic.remede.appartenances', "aucun nombre de tours plus petit ne l'annule : elle tient aux appartenances déclarées"],
|
||||
['diagnostic.remede.animateurs', 'laisser les animateurs sans appartenance déclarée, ou leur en donner des distinctes'],
|
||||
[
|
||||
'diagnostic.remede.ancrage',
|
||||
'retirer des réservations de portée « tous les tours » à cette table, ou lui ajouter des places, la remet en rotation',
|
||||
],
|
||||
[
|
||||
'diagnostic.remede.ecartItineraire',
|
||||
"aucune relance ne le réduit : il tient aux tables pleines dont les capacités ou les ancrages diffèrent",
|
||||
],
|
||||
['diagnostic.remede.places', "ajouter des tables ou des places, ou exclure des personnes : d'ici là, la génération est refusée"],
|
||||
['diagnostic.remede.plafond', 'seules les capacités et les réservations le changent : aucune relance ne le dépasse'],
|
||||
['diagnostic.remede.imposes', 'les réservations les imposent : ils ne comptent pas parmi les retours que choisit le moteur'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,43 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés de l'événement ouvert : son en-tête, son mode, ses
|
||||
// avertissements, le support rompu (§ 14.6). Chaque clé commence par «
|
||||
// evenement. ».
|
||||
export const TABLE = new Map();
|
||||
// Les libellés de l'événement ouvert : son en-tête, ses états, la place fixe
|
||||
// de son cadre de mode, la reprise d'un verrou, ses avertissements, le
|
||||
// support rompu (§ 8.3 à § 8.9, § 9, § 14.6). Chaque clé commence par «
|
||||
// evenement. ». Une donnée — un numéro, une séance, un instant — n'entre
|
||||
// dans aucun de ces textes : l'écran la pose dans son propre nœud, à côté.
|
||||
export const TABLE = new Map([
|
||||
// --- L'en-tête et les états du plan (§ 9) -----------------------------------
|
||||
['evenement.etat.titre', 'État du plan'],
|
||||
['evenement.etat.brouillon', 'Brouillon'],
|
||||
['evenement.etat.propose', 'Proposé'],
|
||||
['evenement.etat.retenu', 'Retenu'],
|
||||
['evenement.etat.bloque', 'Bloqué'],
|
||||
|
||||
// --- Le cadre de mode et sa place fixe (§ 8.3, § 8.4, § 8.5) -------------
|
||||
['evenement.cadre.commandes', 'Mode et historique'],
|
||||
['evenement.commande.defaire', 'Défaire'],
|
||||
['evenement.commande.refaire', 'Refaire'],
|
||||
|
||||
// --- La reprise d'un verrou dont la vie ne se prouve pas (§ 8.8) ----------
|
||||
['evenement.verrou.titre', 'Le verrou qui tient cet événement'],
|
||||
['evenement.verrou.seance', 'Séance'],
|
||||
['evenement.verrou.depuis', 'Pris le'],
|
||||
['evenement.verrou.inconnu', 'illisible'],
|
||||
[
|
||||
'evenement.verrou.avis',
|
||||
"Reprendre ce verrou n'est sûr que si cette séance est arrêtée : deux séances qui écrivent le même événement se contrediraient.",
|
||||
],
|
||||
['evenement.verrou.confirmerReprise', 'Confirmer la reprise de ce verrou'],
|
||||
|
||||
// --- Les avertissements (§ 8.6, § 8.9, § 9) -------------------------------
|
||||
['evenement.avertissements', 'Avertissements'],
|
||||
['evenement.fautive.numero', 'Proposition n°'],
|
||||
['evenement.fautive.rang', 'Proposition illisible, au rang'],
|
||||
['evenement.derive.retenu', 'Placement retenu'],
|
||||
['evenement.derive.proposition', 'Proposition n°'],
|
||||
|
||||
// --- Le support rompu (§ 8.6) ----------------------------------------------
|
||||
['evenement.supportRompu.abandonner', 'Fermer sans écrire ailleurs'],
|
||||
['evenement.supportRompu.confirmerAbandon', "Confirmer : fermer sans l'écrire ailleurs"],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,97 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés de la génération : réglages et avancement (§ 14.6). Chaque clé
|
||||
// commence par « generation. ».
|
||||
export const TABLE = new Map();
|
||||
// Les libellés de la génération : réglages, durée observée, question d'une
|
||||
// génération longue, avancement et issue (§ 5.7, § 5.10, § 14.6). Chaque
|
||||
// clé commence par « generation. ». Le libellé d'un réglage porte son unité,
|
||||
// entre parenthèses : l'étiquette du champ la dit, et le nom accessible du
|
||||
// champ avec elle. Un nombre et une durée se mettent en forme par
|
||||
// creerFormat, sur la composition que la traduction passe à l'entrée ; les
|
||||
// détails reçus sont des nombres.
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { compte } from '../../application/libelles/aides.js';
|
||||
|
||||
// Ce que vaut un calcul mesuré : sa durée, puis ses réglages, nombre de
|
||||
// propositions et compte d'arrêt, dont le produit est le nombre de
|
||||
// mouvements évalués.
|
||||
function calculMesure({ dureeMs, nombre, arret }, texte) {
|
||||
const format = creerFormat(texte);
|
||||
return `${format.duree(dureeMs)} pour ${format.nombre(nombre)} \u{00D7} ${format.nombre(arret)} mouvements`;
|
||||
}
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Les réglages (§ 5.7, § 5.10) ---------------------------------------------
|
||||
['generation.reglage.graine', 'Graine (nombre entier, de 0 à 4\u{202F}294\u{202F}967\u{202F}295)'],
|
||||
['generation.reglage.nombre', 'Propositions par génération (nombre de propositions)'],
|
||||
['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"],
|
||||
['generation.reglage.historique', "Historique d'acceptation (longueur, en mouvements)"],
|
||||
|
||||
// --- La durée du dernier calcul de cette séance (§ 5.10) --------------------
|
||||
[
|
||||
'generation.duree.inconnue',
|
||||
"Aucun calcul n'est encore mesuré sur ce poste pendant cette séance : la durée d'une génération est inconnue.",
|
||||
],
|
||||
['generation.duree.observee', (details, texte) => `Dernier calcul sur ce poste : ${calculMesure(details, texte)}.`],
|
||||
// secondes : l'estimation des réglages courants, d'après le dernier calcul.
|
||||
[
|
||||
'generation.duree.estimee',
|
||||
({ secondes }, texte) => `À ces réglages, une génération prendrait ${creerFormat(texte).ordreDeGrandeur(secondes)}.`,
|
||||
],
|
||||
|
||||
// --- La génération longue, annoncée avant de partir (§ 5.10) ----------------
|
||||
// estimation : celle de CONFIRMATION_REQUISE, { inconnue: true } sans
|
||||
// mesure, sinon { secondes, auDelaDuPlafond, mesure: { dureeMs, mouvements } }.
|
||||
[
|
||||
'generation.longue.question',
|
||||
({ estimation }, texte) => {
|
||||
if (estimation.inconnue === true) {
|
||||
return (
|
||||
"Aucune durée n'est encore mesurée sur ce poste pendant cette séance, et ce réglage demande plus de dix " +
|
||||
'fois le réglage par défaut. La lancer quand même ?'
|
||||
);
|
||||
}
|
||||
const format = creerFormat(texte);
|
||||
const { mesure } = estimation;
|
||||
const plafond = estimation.auDelaDuPlafond ? ", au-delà du plafond d'usage de trente minutes" : '';
|
||||
return (
|
||||
`Cette génération prendrait ${format.ordreDeGrandeur(estimation.secondes)} à ce réglage${plafond}, ` +
|
||||
`d'après le dernier calcul : ${format.duree(mesure.dureeMs)} pour ${format.nombre(mesure.mouvements)} ` +
|
||||
'mouvements. La lancer quand même ?'
|
||||
);
|
||||
},
|
||||
],
|
||||
['generation.longue.confirmer', 'Générer quand même'],
|
||||
['generation.longue.renoncer', 'Ne pas générer'],
|
||||
|
||||
// --- Les commandes ----------------------------------------------------------------
|
||||
['generation.commande.generer', 'Générer'],
|
||||
['generation.commande.annuler', 'Annuler la génération'],
|
||||
|
||||
// --- L'avancement (§ 5.10) --------------------------------------------------------
|
||||
['generation.avancement.barre', 'Avancement de la génération'],
|
||||
[
|
||||
'generation.avancement.texte',
|
||||
({ fait, total }, texte) => {
|
||||
const { nombre } = creerFormat(texte);
|
||||
return `Génération en cours : ${nombre(fait)} mouvements évalués sur ${nombre(total)}.`;
|
||||
},
|
||||
],
|
||||
|
||||
// --- L'issue d'une génération -------------------------------------------------------
|
||||
// ajoutees : le nombre de propositions enregistrées ; identiques : celles
|
||||
// que le plan d'une proposition présente a écartées.
|
||||
[
|
||||
'generation.issue.ajoutees',
|
||||
({ ajoutees, identiques }) => {
|
||||
const nouvelles =
|
||||
ajoutees === 0 ? 'Aucune proposition nouvelle' : `${compte(ajoutees, 'proposition ajoutée', 'propositions ajoutées')}`;
|
||||
const ecartees =
|
||||
identiques === 0
|
||||
? ''
|
||||
: ` ; ${compte(identiques, 'placement identique à une proposition présente, écarté', 'placements identiques à des propositions présentes, écartés')}`;
|
||||
return `${nouvelles}${ecartees}.`;
|
||||
},
|
||||
],
|
||||
['generation.issue.annuleeCommande', "Génération annulée : rien n'est enregistré."],
|
||||
['generation.issue.annuleeSeance', "Génération annulée par le retour en lecture : rien n'est enregistré."],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2,5 +2,133 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés de l'import d'une liste : aperçu et rapport (§ 14.6). Chaque
|
||||
// clé commence par « import. ».
|
||||
export const TABLE = new Map();
|
||||
// clé commence par « import. ». Un nombre, un nom ou un rang s'affiche dans
|
||||
// son propre nœud de texte : les libellés qui le suivent ne reçoivent que ce
|
||||
// qui accorde le nom au compte.
|
||||
|
||||
// Le singulier jusqu'à un, le pluriel au-delà, comme le veut le français.
|
||||
const accorde = (n, singulier, pluriel) => (n <= 1 ? singulier : pluriel);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- La section et ses commandes (§ 10.1, § 10.2) ----------------------------
|
||||
[
|
||||
'import.consigne',
|
||||
'Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. ' +
|
||||
"Rien ne s'importe avant l'aperçu.",
|
||||
],
|
||||
['import.commande.choisirFichier', 'Choisir un fichier CSV\u{2026}'],
|
||||
['import.commande.importer', 'Importer'],
|
||||
['import.commande.fermerApercu', "Fermer l'aperçu"],
|
||||
['import.commande.ordreDeLaGrille', "Lire les colonnes dans l'ordre de la grille"],
|
||||
['import.commande.enregistrerRefus', 'Enregistrer les lignes refusées'],
|
||||
['import.commande.fermerRapport', 'Fermer le rapport'],
|
||||
['import.refusEnregistres', 'Lignes refusées enregistrées :'],
|
||||
|
||||
// --- Le mode de l'import (§ 10.1) -------------------------------------------
|
||||
['import.mode.titre', "Mode de l'import"],
|
||||
['import.mode.ajouter', 'Ajouter'],
|
||||
['import.mode.mettreAJour', 'Mettre à jour'],
|
||||
['import.mode.remplacer', 'Remplacer'],
|
||||
|
||||
// --- L'aperçu : source, encodage, séparateur ---------------------------------
|
||||
['import.apercu.titre', 'Aperçu'],
|
||||
['import.apercu.fichier', 'Fichier'],
|
||||
['import.apercu.collage', 'Texte collé'],
|
||||
['import.apercu.encodage', 'Encodage'],
|
||||
['import.apercu.separateur', 'Séparateur'],
|
||||
['import.encodage.utf8Marque', 'UTF-8 avec marque'],
|
||||
['import.encodage.utf16le', 'UTF-16 LE'],
|
||||
['import.encodage.utf16be', 'UTF-16 BE'],
|
||||
['import.encodage.utf8', 'UTF-8'],
|
||||
['import.encodage.windows1252', 'windows-1252'],
|
||||
['import.separateur.pointVirgule', 'point-virgule'],
|
||||
['import.separateur.virgule', 'virgule'],
|
||||
['import.separateur.tabulation', 'tabulation'],
|
||||
['import.separateur.uneColonne', 'une seule colonne'],
|
||||
|
||||
// --- L'aperçu : les colonnes et leur association (§ 10.1) --------------------
|
||||
['import.apercu.colonnes', 'Colonnes'],
|
||||
['import.apercu.entete', 'En-tête'],
|
||||
['import.apercu.devient', 'Devient'],
|
||||
['import.champ.nom', 'nom'],
|
||||
['import.champ.prenom', 'prénom'],
|
||||
['import.champ.appartenance', 'appartenance'],
|
||||
['import.champ.courriel', 'courriel'],
|
||||
['import.champ.titrePressenti', 'titre pressenti'],
|
||||
['import.champ.exclu', 'exclu'],
|
||||
['import.champ.notes', 'notes'],
|
||||
['import.exclu.oui', 'oui'],
|
||||
['import.exclu.non', 'non'],
|
||||
['import.colonne.nonReconnue', 'non reconnue : ignorée'],
|
||||
['import.colonne.aTrancher', 'à trancher'],
|
||||
['import.colonne.ecartee', 'écartée par le choix'],
|
||||
['import.ambiguite.question', 'Plusieurs colonnes répondent au même champ. Laquelle vaut :'],
|
||||
|
||||
// --- L'aperçu : les lignes ---------------------------------------------------
|
||||
['import.apercu.valides', ({ n }) => accorde(n, 'ligne valide', 'lignes valides')],
|
||||
['import.apercu.refusees', 'Lignes refusées'],
|
||||
['import.apercu.ligne', 'Ligne'],
|
||||
['import.apercu.motif', 'Motif'],
|
||||
['import.apercu.fusions', 'Orthographes fondues en une appartenance'],
|
||||
['import.apercu.orthographeLignes', ({ n }) => accorde(n, 'ligne', 'lignes')],
|
||||
['import.apercu.affichee', 'affichée :'],
|
||||
['import.apercu.doublons', 'Doublons signalés, jamais fusionnés'],
|
||||
['import.apercu.doublonLignes', 'lignes'],
|
||||
['import.apercu.doublonExistants', 'déjà dans la liste :'],
|
||||
['import.apercu.homonymes', "Ajoutées à côté d'une personne de même nom"],
|
||||
['import.apercu.multilignes', 'Cellules sur plusieurs lignes qui portent le séparateur : à vérifier'],
|
||||
|
||||
// --- Les refus globaux : le remède (§ 10.1) ---------------------------------
|
||||
['import.remede.caractereNulCollage', 'Retirez le caractère nul du texte collé, ou importez le fichier réenregistré en UTF-8.'],
|
||||
['import.remede.aucunEntete', "Ajoutez une ligne d'en-tête qui nomme les colonnes, « nom » au moins."],
|
||||
[
|
||||
'import.remede.aucunEnteteCollage',
|
||||
"Ajoutez une ligne d'en-tête qui nomme les colonnes, ou lisez les colonnes dans l'ordre de la grille.",
|
||||
],
|
||||
['import.remede.nomNonAssocie', 'Nommez « nom » la colonne des noms.'],
|
||||
['import.remede.aucuneLigneValide', 'Corrigez les lignes refusées, listées avec leur motif.'],
|
||||
[
|
||||
'import.remede.guillemetOuvert',
|
||||
({ ligne }) => `Fermez le guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''}.`,
|
||||
],
|
||||
[
|
||||
'import.remede.separateurIntrouvable',
|
||||
'Séparez les colonnes par « ; », « , » ou une tabulation, en même nombre à chaque ligne.',
|
||||
],
|
||||
[
|
||||
'import.remede.separateurIntrouvableCollage',
|
||||
"Séparez les colonnes par « ; », « , » ou une tabulation, ou lisez la colonne dans l'ordre de la grille : " +
|
||||
'chaque ligne devient un nom.',
|
||||
],
|
||||
|
||||
// --- La fenêtre de « Remplacer » (§ 10.1) ------------------------------------
|
||||
['import.remplacer.titre', 'Remplacer la liste des participants'],
|
||||
[
|
||||
'import.remplacer.participants',
|
||||
({ n }) => `${n} ${accorde(n, 'personne sera supprimée', 'personnes seront supprimées')}`,
|
||||
],
|
||||
[
|
||||
'import.remplacer.reservations',
|
||||
({ n }) => `${n} ${accorde(n, 'réservation sera supprimée', 'réservations seront supprimées')}`,
|
||||
],
|
||||
[
|
||||
'import.remplacer.titres',
|
||||
({ n }) =>
|
||||
`${n} ${accorde(n, 'titre perdra la réservation qui le pourvoit', 'titres perdront la réservation qui les pourvoit')}, ` +
|
||||
'à un tour au moins',
|
||||
],
|
||||
[
|
||||
'import.remplacer.propositions',
|
||||
({ n }) => `${n} ${accorde(n, 'proposition deviendra périmée', 'propositions deviendront périmées')}`,
|
||||
],
|
||||
|
||||
// --- Le rapport (§ 10.1) ---------------------------------------------------
|
||||
['import.rapport.titre', "Rapport d'import"],
|
||||
['import.rapport.ajoutes', ({ n }) => accorde(n, 'personne ajoutée', 'personnes ajoutées')],
|
||||
['import.rapport.misAJour', ({ n }) => accorde(n, 'personne mise à jour', 'personnes mises à jour')],
|
||||
['import.rapport.inchanges', ({ n }) => accorde(n, 'personne inchangée', 'personnes inchangées')],
|
||||
['import.rapport.retires', ({ n }) => accorde(n, 'personne retirée', 'personnes retirées')],
|
||||
['import.rapport.reservationsRetirees', ({ n }) => accorde(n, 'réservation retirée', 'réservations retirées')],
|
||||
['import.rapport.rienEcrit', "Rien n'a changé dans la liste : seules des lignes refusées restent à lire."],
|
||||
['import.rapport.designees', 'désignées :'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3,4 +3,89 @@
|
|||
|
||||
// Les libellés de la liste des participants : grille, formulaire, fiche (§
|
||||
// 14.6). Chaque clé commence par « participants. ».
|
||||
export const TABLE = new Map();
|
||||
//
|
||||
// participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans
|
||||
// l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle
|
||||
// d'une valeur de la fiche ; participants.statut.* nomme chaque statut
|
||||
// calculé (§ 4.2), par cleDeStatut.
|
||||
|
||||
// Un nombre suivi de son nom, au singulier pour 0 et 1.
|
||||
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
|
||||
|
||||
// La portée d'une réservation : tous les tours, ou le tour désigné.
|
||||
const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'tous les tours' : `tour ${tour}`);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Les colonnes, que la grille, le formulaire et la fiche partagent ------
|
||||
['participants.colonne.nom', 'Nom'],
|
||||
['participants.colonne.prenom', 'Prénom'],
|
||||
['participants.colonne.appartenance', 'Appartenance'],
|
||||
['participants.colonne.courriel', 'Courriel'],
|
||||
['participants.colonne.titrePressenti', 'Titre pressenti'],
|
||||
['participants.colonne.exclu', 'Exclusion'],
|
||||
['participants.colonne.notes', 'Notes'],
|
||||
|
||||
// --- Le formulaire d'ajout (§ 10.2) ---------------------------------------
|
||||
['participants.formulaire.titre', 'Ajouter une personne'],
|
||||
['participants.formulaire.ajouter', 'Ajouter'],
|
||||
['participants.formulaire.orthographe', 'Cette appartenance existe déjà sous l\u{2019}orthographe'],
|
||||
['participants.formulaire.reprendre', 'Reprendre cette orthographe'],
|
||||
|
||||
// --- La grille (§ 10.2) ---------------------------------------------------
|
||||
['participants.grille.titre', 'Liste des participants'],
|
||||
['participants.grille.filtre', 'Filtrer'],
|
||||
[
|
||||
'participants.grille.compte',
|
||||
({ total, affiches }) =>
|
||||
affiches === total ? compte(total, 'participant', 'participants') : `${affiches} sur ${compte(total, 'participant', 'participants')}`,
|
||||
],
|
||||
['participants.grille.croissant', '(croissant)'],
|
||||
['participants.grille.decroissant', '(décroissant)'],
|
||||
['participants.grille.actions', 'Actions'],
|
||||
['participants.grille.exclue', 'exclue'],
|
||||
['participants.grille.presente', 'présente'],
|
||||
['participants.grille.vide', 'Aucun participant. Ajoutez une personne, ou collez une liste dans la grille.'],
|
||||
['participants.grille.aucunResultat', 'Aucune personne ne correspond au filtre.'],
|
||||
|
||||
// --- Les commandes de la section ------------------------------------------
|
||||
['participants.commande.fiche', 'Fiche'],
|
||||
['participants.commande.exclure', 'Exclure'],
|
||||
['participants.commande.reintegrer', 'Réintégrer'],
|
||||
['participants.commande.supprimer', 'Supprimer'],
|
||||
['participants.commande.exporter', 'Exporter la liste'],
|
||||
['participants.commande.fermerFiche', 'Fermer la fiche'],
|
||||
|
||||
// --- La suppression d'une personne qui porte des réservations (§ 2.1) -----
|
||||
['participants.suppression.titre', ({ personne }) => `Supprimer ${personne} et ses réservations ?`],
|
||||
[
|
||||
'participants.suppression.reservation',
|
||||
(details) =>
|
||||
`Réservation à la table ${details.numero}${details.siege === null ? '' : `, siège ${details.siege}`}, ${portee(details)}`,
|
||||
],
|
||||
|
||||
// --- La fiche (§ 4, § 4.2, § 7.4) -----------------------------------------
|
||||
['participants.fiche.titre', 'Fiche de la personne'],
|
||||
['participants.fiche.vide', '\u{2014}'],
|
||||
['participants.fiche.statut', 'Statut'],
|
||||
['participants.fiche.statutExclue', 'exclue : ni placée, ni comptée'],
|
||||
['participants.fiche.statutInconnu', 'non calculé : la configuration est incohérente'],
|
||||
['participants.fiche.reservations', 'Réservations'],
|
||||
['participants.fiche.aucuneReservation', 'Aucune réservation.'],
|
||||
['participants.fiche.suspendues', 'Suspendues tant que la personne est exclue.'],
|
||||
['participants.fiche.table', 'Table'],
|
||||
['participants.fiche.siege', 'Siège'],
|
||||
['participants.fiche.portee', 'Portée'],
|
||||
['participants.fiche.sansSiege', 'sans siège'],
|
||||
['participants.fiche.tousLesTours', 'tous les tours'],
|
||||
['participants.fiche.auTour', 'tour'],
|
||||
['participants.fiche.itineraire', 'Placement retenu, tour par tour'],
|
||||
['participants.fiche.tour', 'Tour'],
|
||||
['participants.fiche.enReserve', 'en réserve'],
|
||||
['participants.fiche.siegeNonAttribue', 'non attribué'],
|
||||
['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'],
|
||||
|
||||
// --- Les statuts calculés (§ 4.2) -----------------------------------------
|
||||
['participants.statut.ancre', 'ancré'],
|
||||
['participants.statut.partiel', 'partiellement fixé'],
|
||||
['participants.statut.mobile', 'mobile'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3,4 +3,180 @@
|
|||
|
||||
// Les libellés du plan de salle : vue, tables, places, panneaux et listes de
|
||||
// travail (§ 14.6). Chaque clé commence par « plan. ».
|
||||
export const TABLE = new Map();
|
||||
//
|
||||
// Une ligne de la liste de noms (§ 7.6) se compose de parties, chacune son
|
||||
// tspan : la tête — numéro, puis état de la place —, le nom, puis le titre
|
||||
// entre ce qui le précède et ce qui le suit. Le nom et le libellé du titre
|
||||
// sont des données : aucun texte de cette table ne les porte. La tête finit
|
||||
// par une espace quand un nom la suit (avecNom).
|
||||
import { compte, enumeration } from '../../application/libelles/aides.js';
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Le dessin d'une table (§ 7.5, § 7.6) ----------------------------------
|
||||
// Le nom accessible d'une table : numéro, places, conflits du tour quand
|
||||
// ils s'évaluent (conflits null sinon), puis gelée et surchargée.
|
||||
[
|
||||
'plan.table.nom',
|
||||
({ numero, places, conflits, gelee, surchargee }) =>
|
||||
[
|
||||
`Table ${numero}`,
|
||||
compte(places, 'place', 'places'),
|
||||
...(conflits === null ? [] : [conflits === 0 ? 'aucun conflit' : compte(conflits, 'conflit', 'conflits')]),
|
||||
...(gelee ? ['gelée'] : []),
|
||||
...(surchargee ? ['surchargée'] : []),
|
||||
].join(', '),
|
||||
],
|
||||
|
||||
// --- La liste de noms (§ 7.4, § 7.6) ---------------------------------------
|
||||
['plan.liste.gelee', 'table gelée'],
|
||||
['plan.liste.abreviation', '\u{2026}'],
|
||||
[
|
||||
'plan.ligne.tete',
|
||||
({ numero, etat, avecNom }, texte) =>
|
||||
`${numero}.${etat === null ? '' : ` ${texte(etat.cle, etat.details)}`}${avecNom ? ' ' : ''}`,
|
||||
],
|
||||
['plan.ligne.reserveeTous', '(réservée, tous les tours)'],
|
||||
['plan.ligne.reserveeTour', ({ tour }) => `(réservée, tour ${tour})`],
|
||||
['plan.ligne.libre', '(libre)'],
|
||||
// Une place réservée à une autre personne que l'occupant : l'état
|
||||
// s'écrit autour du nom de la titulaire, qui occupe son propre nœud,
|
||||
// entre teteReserveeA et reserveeA.
|
||||
['plan.ligne.teteReserveeA', ({ numero }) => `${numero}. (réservée à `],
|
||||
['plan.ligne.reserveeA', ({ portee, tour }) => (portee === 'tous' ? ', tous les tours)' : `, tour ${tour})`)],
|
||||
['plan.ligne.avantTitrePourvu', ' \u{2014} '],
|
||||
['plan.ligne.avantTitreNonPourvu', ' \u{2014} titre '],
|
||||
['plan.ligne.apresTitreNonPourvu', ' non pourvu'],
|
||||
|
||||
// --- Le bandeau du plan (§ 7.5) --------------------------------------------
|
||||
// L'appartenance d'un plancher de groupe est une donnée : l'écran la pose
|
||||
// dans son propre nœud, avant ce texte.
|
||||
['plan.bandeau.toutesEnConflit', 'toutes les tables sont en conflit à ce tour'],
|
||||
[
|
||||
'plan.bandeau.collisionPartout',
|
||||
'tout placement porte une collision à chaque table : ajouter des tables ou désactiver la séparation',
|
||||
],
|
||||
[
|
||||
'plan.bandeau.uneSeuleAppartenance',
|
||||
'une seule appartenance sous séparation active : chaque table réunit des personnes de la même appartenance',
|
||||
],
|
||||
[
|
||||
'plan.bandeau.plancherGroupe',
|
||||
({ effectif, plancherCumulees }) =>
|
||||
`${compte(effectif, 'personne', 'personnes')}, au moins ${compte(plancherCumulees, 'collision', 'collisions')} sur la soirée`,
|
||||
],
|
||||
['plan.bandeau.sansPlancher', 'le diagnostic ne prouve aucun plancher de collisions'],
|
||||
|
||||
// --- L'échelle graphique et la mention (§ 7.7) -----------------------------
|
||||
['plan.echelle.mention', "disposition relative \u{2014} l'encombrement dans la salle n'est pas vérifié"],
|
||||
['plan.echelle.nom', 'Échelle graphique'],
|
||||
['plan.echelle.centimetres', 'cm'],
|
||||
['plan.echelle.metres', 'm'],
|
||||
['plan.echelle.kilometres', 'km'],
|
||||
|
||||
// --- La barre de vue (§ 7.2, § 7.7, § 8.4) ---------------------------------
|
||||
// Le numéro d'un tour, dans le sélecteur, est une donnée : son option ne
|
||||
// porte que lui.
|
||||
['plan.vue.barre', 'Vue du plan'],
|
||||
['plan.vue.tour', 'Tour affiché'],
|
||||
['plan.vue.zoomer', 'Zoom +'],
|
||||
['plan.vue.dezoomer', 'Zoom \u{2212}'],
|
||||
['plan.vue.ajuster', 'Ajuster'],
|
||||
['plan.vue.listes', 'Listes de noms'],
|
||||
['plan.vue.pleinEcran', 'Plein écran'],
|
||||
['plan.vue.dessin', 'Plan de salle'],
|
||||
|
||||
// --- Les gestes du pointeur (§ 5.8, § 7.1, § 7.3) -------------------------
|
||||
// Le fantôme d'une personne glissée dit ce que le geste modifie : l'état
|
||||
// des données, non le geste (§ 5.8). Le nom de la personne est une donnée,
|
||||
// dans son propre nœud.
|
||||
['plan.fantome.reservations', 'pose une réservation'],
|
||||
['plan.fantome.retenu', 'déplace dans le placement retenu'],
|
||||
['plan.fantome.vueSeule', 'proposition en vue seule : rien ne change'],
|
||||
// La place où un refus ou un avertissement du plan se pose.
|
||||
['plan.refus.table', ({ numero }) => `Table ${numero}`],
|
||||
['plan.refus.siege', ({ numero, siege }) => `Table ${numero}, siège ${siege}`],
|
||||
['plan.refus.fermer', 'Fermer'],
|
||||
|
||||
// --- Le clavier et l'infobulle (§ 7.1, § 7.3, § 13.5) ----------------------
|
||||
// Le plan est un seul arrêt de tabulation : sa description dit comment s'y
|
||||
// déplacer. L'infobulle d'une table en conflit nomme chaque règle violée,
|
||||
// puis ses groupes : les noms sont des données, chacun dans son nœud,
|
||||
// reliés par « et » dans une paire et séparés d'un groupe à l'autre.
|
||||
['plan.vue.role', 'plan de salle interactif'],
|
||||
['plan.infobulle.entete', ({ numero, nombre }) => `Table ${numero} : ${compte(nombre, 'conflit', 'conflits')}`],
|
||||
['plan.infobulle.separerAppartenances', 'appartenances réunies : '],
|
||||
['plan.infobulle.nouveauxVoisins', 'voisins revus : '],
|
||||
['plan.infobulle.nouvelleTable', 'table revue : '],
|
||||
['plan.infobulle.et', ' et '],
|
||||
['plan.infobulle.separateur', ', '],
|
||||
|
||||
// --- Les panneaux latéraux (§ 2.1, étape 8) --------------------------------
|
||||
['plan.panneaux', 'Panneaux du plan'],
|
||||
['plan.listesTravail.titre', 'Listes de travail'],
|
||||
['plan.reserve.titre', 'Réserve'],
|
||||
|
||||
// --- Le choix en attente (§ 5.8, § 7.1) ------------------------------------
|
||||
// Le nom de la personne choisie est une donnée, dans son propre nœud.
|
||||
['plan.choix.personne', 'Personne choisie : '],
|
||||
['plan.choix.reservation', 'Réservation choisie : '],
|
||||
['plan.choix.oublier', 'Oublier le choix'],
|
||||
[
|
||||
'plan.choix.inviter',
|
||||
"Choisissez d'abord une personne : dans la réserve, dans la liste des pressentis, ou par « Déplacer » dans son menu.",
|
||||
],
|
||||
|
||||
// --- Le panneau d'une place (§ 7.1, § 2.1 étape 6, § 4) --------------------
|
||||
['plan.action.assigner', 'Assigner'],
|
||||
['plan.action.reserver', 'Réserver'],
|
||||
['plan.action.donnerTitre', 'Donner un titre'],
|
||||
['plan.action.designer', 'Désigner'],
|
||||
['plan.action.retirerReservation', 'Retirer la réservation'],
|
||||
['plan.action.retirerTitre', 'Retirer le titre'],
|
||||
['plan.action.deplacerReservationIci', 'Déplacer la réservation ici'],
|
||||
['plan.portee.etiquette', 'Portée'],
|
||||
['plan.portee.tour', ({ tour }) => `Tour ${tour}`],
|
||||
['plan.portee.tous', 'Tous les tours'],
|
||||
['plan.titre.etiquette', 'Libellé du titre'],
|
||||
|
||||
// --- Le panneau d'une table (§ 7.3, § 2.1 étape 6) -------------------------
|
||||
['plan.panneauTable.sieges', 'Sièges'],
|
||||
[
|
||||
'plan.panneauTable.titres',
|
||||
({ titres, pourvus }) => `${compte(titres, 'titre', 'titres')} dont ${compte(pourvus, 'pourvu', 'pourvus')}`,
|
||||
],
|
||||
['plan.panneauTable.siege', 'Siège'],
|
||||
|
||||
// --- Le menu par personne (§ 7.1) ------------------------------------------
|
||||
// Le nom de la personne, titre du menu, est une donnée dans son nœud.
|
||||
['plan.menu.titre', 'Menu de '],
|
||||
['plan.menu.deplacer', 'Déplacer'],
|
||||
['plan.menu.echanger', 'Échanger'],
|
||||
['plan.menu.retirerDuTour', 'Retirer du tour'],
|
||||
['plan.menu.reserverIci', 'Réserver ici'],
|
||||
['plan.menu.deplacerReservation', 'Déplacer sa réservation'],
|
||||
['plan.menu.exclure', 'Exclure'],
|
||||
['plan.menu.reintegrer', 'Réintégrer'],
|
||||
['plan.menu.ouvrirFiche', 'Ouvrir la fiche'],
|
||||
|
||||
// --- Les listes de travail (§ 5.8, § 4.1, § 4.2) ---------------------------
|
||||
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
|
||||
// des données, chacun dans son nœud, entre ces textes.
|
||||
['plan.listesTravail.titres', 'Titres de place non pourvus'],
|
||||
['plan.listesTravail.pressentis', 'Titres pressentis sans réservation'],
|
||||
['plan.listesTravail.conversions', 'Conversions proposées'],
|
||||
['plan.listesTravail.aucun', 'Aucun.'],
|
||||
['plan.listesTravail.titreNonPourvu', ({ numero }) => `table ${numero} : titre `],
|
||||
['plan.listesTravail.nonPourvu', ' non pourvu'],
|
||||
[
|
||||
'plan.listesTravail.pourvuAuxTours',
|
||||
({ tours }) => ` (pourvu ${tours.length === 1 ? 'au tour' : 'aux tours'} ${enumeration(tours.map(String))})`,
|
||||
],
|
||||
['plan.listesTravail.pressenti', ' \u{2014} titre pressenti '],
|
||||
['plan.listesTravail.conversion', ({ numero }) => ` : une réservation à chaque tour, table ${numero}`],
|
||||
['plan.listesTravail.convertir', 'Convertir en « tous les tours »'],
|
||||
['plan.listesTravail.ignorer', 'Ignorer'],
|
||||
|
||||
// --- La réserve (§ 7.1, § 5.8) ---------------------------------------------
|
||||
['plan.reserve.tour', ({ tour }) => `Personnes en attente au tour ${tour}`],
|
||||
['plan.reserve.vide', 'Personne en attente à ce tour.'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,107 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés du tableau des propositions (§ 14.6). Chaque clé commence par «
|
||||
// propositions. ».
|
||||
export const TABLE = new Map();
|
||||
// Les libellés du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9,
|
||||
// § 14.6). Chaque clé commence par « propositions. ».
|
||||
//
|
||||
// Chaque en-tête de colonne nomme son unité (§ 5.4) : collisions cumulées,
|
||||
// paires distinctes et excédent ne se confondent jamais. Des rencontres, le
|
||||
// tableau ne porte que le minimum, sur les trois populations du § 5.4 ; la
|
||||
// moyenne n'y paraît pas. La phrase de l'ordre compose les noms des critères
|
||||
// par leurs clés moteur.critere.<nom>, dans l'ordre reçu, puis l'identifiant
|
||||
// croissant, qui départage deux propositions égales sur tous (§ 5.7).
|
||||
import { compte } from '../../application/libelles/aides.js';
|
||||
|
||||
// Le nom d'un critère du classement, celui de la table du moteur.
|
||||
const critere = (nom, texte) => texte(`moteur.critere.${nom}`);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- La section (§ 5.7) ------------------------------------------------------
|
||||
['propositions.tableau.titre', 'Propositions classées'],
|
||||
['propositions.aucune', "Aucune proposition : la génération en produit, et chaque génération s'ajoute aux précédentes."],
|
||||
[
|
||||
'propositions.ordre.criteres',
|
||||
({ criteres }, texte) =>
|
||||
`Ordre du classement, chaque critère au plus faible d'abord : ${criteres
|
||||
.map((nom) => critere(nom, texte))
|
||||
.join(', puis ')}, puis l'identifiant croissant.`,
|
||||
],
|
||||
[
|
||||
'propositions.ordre.saute',
|
||||
({ saute, applique }, texte) =>
|
||||
`Critère sauté, faute de plafond a priori par personne : ${critere(saute, texte)}. ` +
|
||||
`Appliqué à sa place : ${critere(applique, texte)}.`,
|
||||
],
|
||||
|
||||
// --- Les colonnes, chacune avec son unité (§ 5.4, § 5.5) ----------------------
|
||||
['propositions.colonne.rang', 'Rang'],
|
||||
['propositions.colonne.proposition', 'Proposition'],
|
||||
['propositions.colonne.rencontresTous', 'Personnes distinctes rencontrées, minimum : tous'],
|
||||
['propositions.colonne.rencontresMobiles', 'Personnes distinctes rencontrées, minimum : mobiles'],
|
||||
['propositions.colonne.rencontresAncres', 'Personnes distinctes rencontrées, minimum : ancrés'],
|
||||
['propositions.colonne.collisionsCumulees', 'Collisions cumulées (paire et tour)'],
|
||||
['propositions.colonne.pairesDistinctes', 'Paires distinctes de même appartenance'],
|
||||
['propositions.colonne.excedentCollisions', 'Excédent de collisions (retrouvailles au-delà de la première)'],
|
||||
['propositions.colonne.rencontresRepetees', 'Rencontres répétées choisies (paires)'],
|
||||
['propositions.colonne.maxRencontresPaire', "Rencontres d'une même paire, au plus"],
|
||||
['propositions.colonne.retoursChoisis', 'Retours choisis à une table déjà occupée'],
|
||||
['propositions.colonne.appartenancesVues', 'Appartenances rencontrées A(p), minimum'],
|
||||
['propositions.colonne.totalRedondance', "Redondance d'appartenance, somme Σ r(p)"],
|
||||
['propositions.colonne.diversite', 'Taux de diversité d(p), minimum'],
|
||||
['propositions.colonne.manqueMax', 'Plus grand manque (personnes)'],
|
||||
['propositions.colonne.effectifManque', 'Personnes à qui il en manque'],
|
||||
['propositions.colonne.ecartItineraireMax', "Écart d'itinéraire maximal (personnes)"],
|
||||
['propositions.colonne.ecartAuPlafondAPriori', 'Écart au plafond a priori maximal (personnes)'],
|
||||
['propositions.colonne.certificat', 'Certificat'],
|
||||
['propositions.colonne.imposes', 'Imposé par les réservations'],
|
||||
['propositions.colonne.commandes', 'Commandes'],
|
||||
|
||||
// --- Les valeurs écrites en mots (§ 5.5, § 12.6) ------------------------------
|
||||
['propositions.valeur.inconnu', 'inconnu'],
|
||||
['propositions.certificat.atteint', 'minimum atteint'],
|
||||
['propositions.certificat.inconnue', 'atteignabilité inconnue'],
|
||||
|
||||
// --- Ce que les réservations imposent, à part (§ 5.4) --------------------------
|
||||
[
|
||||
'propositions.imposes.retours',
|
||||
({ retours, ancrages }) =>
|
||||
retours === 0
|
||||
? 'Aucun retour imposé.'
|
||||
: `${compte(retours, 'retour imposé', 'retours imposés')} par ${compte(ancrages, 'ancrage', 'ancrages')}.`,
|
||||
],
|
||||
[
|
||||
'propositions.imposes.repetees',
|
||||
({ repetees }) =>
|
||||
repetees === 0
|
||||
? 'Aucune rencontre répétée imposée.'
|
||||
: `${compte(repetees, 'rencontre répétée imposée', 'rencontres répétées imposées')}.`,
|
||||
],
|
||||
|
||||
// --- Les lignes (§ 5.7, § 9, § 12.6) -----------------------------------------
|
||||
['propositions.marque.retenue', 'retenue'],
|
||||
['propositions.retenu.titre', 'Placement retenu'],
|
||||
['propositions.retenu.origine', ({ proposition }) => `Tel que la proposition n° ${proposition}.`],
|
||||
['propositions.retenu.ajuste', ({ proposition }) => `Ajusté à la main depuis la proposition n° ${proposition}.`],
|
||||
['propositions.horsClassement.titre', 'Hors classement'],
|
||||
['propositions.statut.derive', 'Non comparable : la proposition ne décrit plus la configuration courante.'],
|
||||
['propositions.statut.fautif', 'Non comparable : le placement se contredit.'],
|
||||
|
||||
// --- Les commandes (§ 2.1 étape 10, § 5.7) ------------------------------------
|
||||
['propositions.commande.voir', 'Voir sur le plan'],
|
||||
['propositions.commande.retenir', 'Retenir'],
|
||||
['propositions.commande.effacer', 'Effacer les propositions'],
|
||||
|
||||
// --- Retenir sur un retenu ajusté (§ 2.1) ----------------------------------------
|
||||
[
|
||||
'propositions.confirmation.titre',
|
||||
({ proposition }) => `Retenir la proposition n° ${proposition} détruit les ajustements du placement retenu`,
|
||||
],
|
||||
[
|
||||
'propositions.confirmation.retenuAjuste',
|
||||
({ proposition }) => `Le placement retenu, ajusté à la main depuis la proposition n° ${proposition}`,
|
||||
],
|
||||
[
|
||||
'propositions.confirmation.remplacant',
|
||||
({ proposition }) => `remplacé par la proposition n° ${proposition}, telle que générée`,
|
||||
],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,131 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés des tables et des tours (§ 14.6). Chaque clé commence par «
|
||||
// tables. ».
|
||||
export const TABLE = new Map();
|
||||
// Les libellés des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5,
|
||||
// § 14.6). Chaque clé commence par « tables. ».
|
||||
//
|
||||
// L'énoncé d'un changement de défaut se compose de phrases, que rend
|
||||
// enonceDefaut (tables/modele.js) et que l'écran sépare d'une espace : la
|
||||
// phrase d'application, puis celle du total — le § 6.1 les écrit ainsi. Une
|
||||
// baisse refusée n'a qu'une phrase d'en-tête, puis le refus de chaque table,
|
||||
// composé par texte depuis son code. Les noms de personnes et les numéros de
|
||||
// table viennent des détails : une fenêtre à confirmer nomme chaque élément
|
||||
// détruit dans son propre libellé.
|
||||
import { cite, compte } from '../../application/libelles/aides.js';
|
||||
|
||||
// Un accord au nombre : le singulier jusqu'à un, comme compte.
|
||||
const accord = (n, singulier, pluriel) => (n <= 1 ? singulier : pluriel);
|
||||
|
||||
// La portée d'une réservation en mots : tous les tours, ou le tour désigné.
|
||||
const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'pour tous les tours' : `au tour ${tour}`);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Le défaut (§ 6.1) ------------------------------------------------------
|
||||
['tables.defaut.etiquette', 'Places par défaut'],
|
||||
[
|
||||
'tables.defaut.application',
|
||||
({ suivent, avant, apres, surchargees }) => {
|
||||
const suivi =
|
||||
suivent === 0
|
||||
? 'Aucune table ne suit le défaut'
|
||||
: `${compte(suivent, 'table suit', 'tables suivent')} le défaut et ` +
|
||||
`${accord(suivent, 'passe', 'passent')} de ${avant} à ${compte(apres, 'place', 'places')}`;
|
||||
if (surchargees.length === 0) return `${suivi}.`;
|
||||
return (
|
||||
`${suivi} ; ${compte(surchargees.length, 'table surchargée', 'tables surchargées')} ` +
|
||||
`(${surchargees.join(', ')}) ${accord(surchargees.length, 'ne change', 'ne changent')} pas.`
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'tables.defaut.total',
|
||||
({ totalAvant, totalApres, participants }) =>
|
||||
`Total : ${totalAvant} \u{2192} ${compte(totalApres, 'siège', 'sièges')} ` +
|
||||
`pour ${compte(participants, 'participant', 'participants')}.`,
|
||||
],
|
||||
[
|
||||
'tables.defaut.refusee',
|
||||
({ avant, apres }) =>
|
||||
`Le défaut ne passe pas de ${avant} à ${compte(apres, 'place', 'places')} : ` +
|
||||
'une table au moins refuse, et rien ne change.',
|
||||
],
|
||||
[
|
||||
'tables.defaut.realignementRefuse',
|
||||
({ avant, apres }) =>
|
||||
`Le réalignement sur ${compte(apres, 'place', 'places')} est refusé : une table au moins refuse, ` +
|
||||
`et rien ne change ; le défaut peut passer de ${avant} à ${apres} sans réaligner.`,
|
||||
],
|
||||
// Une table qui refuse le changement : son numéro, puis son refus ; numero
|
||||
// null pour un refus de l'événement entier, qui ne nomme aucune table.
|
||||
[
|
||||
'tables.defaut.refusTable',
|
||||
({ numero, refus }, texte) => {
|
||||
const motif = texte(refus.code, refus.details);
|
||||
return numero === null ? motif : `Table ${numero} \u{2014} ${motif}`;
|
||||
},
|
||||
],
|
||||
['tables.defaut.appliquer', 'Appliquer'],
|
||||
[
|
||||
'tables.defaut.appliquerRealigner',
|
||||
({ n }) => `Appliquer et réaligner ${n <= 1 ? 'la surchargée' : `les ${n} surchargées`}`,
|
||||
],
|
||||
['tables.defaut.garder', 'Garder le défaut actuel'],
|
||||
[
|
||||
'tables.defaut.confirmationRealigner',
|
||||
({ n }) =>
|
||||
`Réaligner ${n <= 1 ? 'la table surchargée' : `les ${n} tables surchargées`} ? ` +
|
||||
`${accord(n, 'Sa surcharge est perdue', 'Leurs surcharges sont perdues')} :`,
|
||||
],
|
||||
['tables.defaut.surchargePerdue', ({ numero, sieges }) => `Table ${numero} : ${compte(sieges, 'place', 'places')}`],
|
||||
|
||||
// --- La liste des tables (§ 4, § 6.1, § 6.2) ----------------------------------
|
||||
['tables.liste.titre', 'Tables'],
|
||||
['tables.liste.aucune', 'Aucune table : ajoutez-en une, ronde ou carrée.'],
|
||||
['tables.liste.table', 'Table'],
|
||||
['tables.liste.places', 'Places'],
|
||||
['tables.liste.realigner', 'Réaligner'],
|
||||
['tables.liste.supprimer', 'Supprimer'],
|
||||
['tables.forme.ronde', 'Ronde'],
|
||||
['tables.forme.carree', 'Carrée'],
|
||||
['tables.etat.suitDefaut', 'Suit le défaut'],
|
||||
['tables.etat.surchargee', 'Surchargée'],
|
||||
['tables.creer.ronde', 'Ajouter une table ronde'],
|
||||
['tables.creer.carree', 'Ajouter une table carrée'],
|
||||
|
||||
// Ce qu'un refus SIEGES_RETIRES retire, siège par siège : le nom de la
|
||||
// personne réservée suit, dans son propre nœud.
|
||||
['tables.siegeRetire.titre', ({ siege, libelle }) => `Siège ${siege} : titre ${cite(libelle)}`],
|
||||
[
|
||||
'tables.siegeRetire.reserve',
|
||||
({ siege, suspendue }) => `Siège ${siege}${suspendue ? ', réservation suspendue' : ''} :`,
|
||||
],
|
||||
|
||||
// --- La suppression d'une table (§ 6.2) -----------------------------------
|
||||
[
|
||||
'tables.suppression.confirmation',
|
||||
({ numero }) => `Supprimer la table ${numero} ? Ces saisies partent avec elle :`,
|
||||
],
|
||||
[
|
||||
'tables.suppression.reservation',
|
||||
(reservation) =>
|
||||
`${reservation.nom} : réservation ${portee(reservation)}` +
|
||||
`${reservation.siege === null ? '' : `, siège ${reservation.siege}`}`,
|
||||
],
|
||||
['tables.suppression.titre', ({ siege, libelle }) => `Titre ${cite(libelle)}, siège ${siege}`],
|
||||
|
||||
// --- Les tours (§ 2.1 étape 5, § 4.3) ----------------------------------------
|
||||
['tables.tours.etiquette', 'Nombre de tours'],
|
||||
[
|
||||
'tables.tours.confirmation',
|
||||
({ avant, apres }) => `Passer de ${avant} à ${compte(apres, 'tour', 'tours')} ? Ce changement emporte :`,
|
||||
],
|
||||
[
|
||||
'tables.tours.reservation',
|
||||
({ nom, numero, tour }) => `${nom} : réservation à la table ${numero} au tour ${tour}, détruite`,
|
||||
],
|
||||
|
||||
// Un placement qu'un changement laisse en dérive (§ 9) : il reste, et ne
|
||||
// correspond plus à la configuration.
|
||||
['tables.touchee.proposition', ({ id }) => `Proposition n° ${id}, qui ne correspondra plus à la configuration`],
|
||||
['tables.touchee.retenu', 'Placement retenu, qui ne correspondra plus à la configuration'],
|
||||
]);
|
||||
|
|
|
|||
45
src/interface/mode.js
Normal file
45
src/interface/mode.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Ce que le bandeau dit de l'événement ouvert, en clés de la table de la
|
||||
// coquille : le libellé de mode (§ 8.4, § 8.5), calculé ici une fois pour le
|
||||
// bandeau et pour le cadre de mode, et l'annonce de la version de format du
|
||||
// fichier (§ 18.6). Les deux se lisent dans l'état observable de la séance.
|
||||
|
||||
// La clé de chaque annonce de format de la séance.
|
||||
const ANNONCES = new Map([
|
||||
['plusRecent', 'coquille.format.plusRecent'],
|
||||
['plusAncien', 'coquille.format.plusAncien'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* La clé du libellé de mode : null sans événement ; 'coquille.mode.bloque'
|
||||
* pour un plan bloqué, que la séance tient en lecture ;
|
||||
* 'coquille.mode.lecture' ou 'coquille.mode.ecriture' sinon, le mode de la
|
||||
* séance.
|
||||
*
|
||||
* @param {{mode: 'lecture'|'ecriture', evenement: null|{etat: string}}} etatSeance
|
||||
* @returns {null|'coquille.mode.lecture'|'coquille.mode.ecriture'|'coquille.mode.bloque'}
|
||||
*/
|
||||
export function cleDeMode(etatSeance) {
|
||||
if (etatSeance.evenement === null) return null;
|
||||
if (etatSeance.evenement.etat === 'bloque') return 'coquille.mode.bloque';
|
||||
return etatSeance.mode === 'ecriture' ? 'coquille.mode.ecriture' : 'coquille.mode.lecture';
|
||||
}
|
||||
|
||||
/**
|
||||
* La clé de l'annonce de format du bandeau : null sans événement ou quand le
|
||||
* fichier est au format courant ; sinon celle de l'annonce de la séance,
|
||||
* plus récent ou plus ancien.
|
||||
*
|
||||
* @param {{evenement: null|{annonceFormat: null|'plusRecent'|'plusAncien'}}} etatSeance
|
||||
* @returns {null|'coquille.format.plusRecent'|'coquille.format.plusAncien'}
|
||||
* @throws {RangeError} une annonce que la séance ne rend pas
|
||||
*/
|
||||
export function cleAnnonceFormat(etatSeance) {
|
||||
const annonce = etatSeance.evenement?.annonceFormat ?? null;
|
||||
if (annonce === null) return null;
|
||||
const cle = ANNONCES.get(annonce);
|
||||
if (cle === undefined) throw new RangeError(`annonce de format inconnue : ${JSON.stringify(annonce)}`);
|
||||
return cle;
|
||||
}
|
||||
66
src/interface/mode.test.js
Normal file
66
src/interface/mode.test.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La clé du libellé de mode (§ 8.4, § 8.5), que lisent le bandeau et le
|
||||
// cadre de mode : aucune sans événement ; le plan bloqué avant le mode, qu'il
|
||||
// force en lecture ; sinon le mode de la séance. Et la clé de l'annonce de
|
||||
// format du bandeau (§ 18.6), une par annonce de la séance.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { TABLE as COQUILLE } from './libelles/coquille.js';
|
||||
import { cleAnnonceFormat, cleDeMode } from './mode.js';
|
||||
|
||||
// Un EtatSeance réduit à ce que lit cleDeMode.
|
||||
const etat = (mode, etatDuPlan, annonceFormat = null) => ({
|
||||
mode,
|
||||
evenement: etatDuPlan === null ? null : { base: 'bal', id: 'evt-1', nom: 'Bal', etat: etatDuPlan, revision: 1, annonceFormat },
|
||||
});
|
||||
|
||||
describe('cleDeMode (§ 8.4)', () => {
|
||||
test('null sans événement, dans les deux modes', () => {
|
||||
assert.equal(cleDeMode(etat('lecture', null)), null);
|
||||
assert.equal(cleDeMode(etat('ecriture', null)), null);
|
||||
});
|
||||
|
||||
test('un plan bloqué rend coquille.mode.bloque, quel que soit le mode', () => {
|
||||
assert.equal(cleDeMode(etat('lecture', 'bloque')), 'coquille.mode.bloque');
|
||||
assert.equal(cleDeMode(etat('ecriture', 'bloque')), 'coquille.mode.bloque');
|
||||
});
|
||||
|
||||
test('sinon le mode de la séance, pour chaque état non bloqué', () => {
|
||||
const etats = ['brouillon', 'propose', 'retenu'];
|
||||
assert.ok(etats.length > 0);
|
||||
for (const etatDuPlan of etats) {
|
||||
assert.equal(cleDeMode(etat('lecture', etatDuPlan)), 'coquille.mode.lecture', etatDuPlan);
|
||||
assert.equal(cleDeMode(etat('ecriture', etatDuPlan)), 'coquille.mode.ecriture', etatDuPlan);
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque clé rendue a son texte dans la table de la coquille', () => {
|
||||
for (const cle of ['coquille.mode.lecture', 'coquille.mode.ecriture', 'coquille.mode.bloque']) {
|
||||
assert.equal(typeof COQUILLE.get(cle), 'string', cle);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleAnnonceFormat (§ 18.6)', () => {
|
||||
test('null sans événement, ou au format courant', () => {
|
||||
assert.equal(cleAnnonceFormat(etat('lecture', null)), null);
|
||||
assert.equal(cleAnnonceFormat(etat('lecture', 'brouillon')), null);
|
||||
});
|
||||
|
||||
test('une clé par annonce, chacune avec son texte', () => {
|
||||
const cas = [
|
||||
['plusRecent', 'coquille.format.plusRecent'],
|
||||
['plusAncien', 'coquille.format.plusAncien'],
|
||||
];
|
||||
for (const [annonce, cle] of cas) {
|
||||
assert.equal(cleAnnonceFormat(etat('lecture', 'brouillon', annonce)), cle, annonce);
|
||||
assert.equal(typeof COQUILLE.get(cle), 'string', cle);
|
||||
}
|
||||
});
|
||||
|
||||
test('une annonce inconnue lève RangeError', () => {
|
||||
assert.throws(() => cleAnnonceFormat(etat('lecture', 'brouillon', 'plusVieux')), RangeError);
|
||||
});
|
||||
});
|
||||
214
src/interface/participants/Fiche.svelte
Normal file
214
src/interface/participants/Fiche.svelte
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom,
|
||||
// appartenance, courriel, titre pressenti, notes, statut calculé, ses
|
||||
// réservations, et sa table et son siège à chaque tour du retenu. La fiche
|
||||
// replie, ne tronque jamais : un nom de deux cents caractères s'y lit
|
||||
// entier, sur autant de lignes qu'il en faut. Chaque donnée occupe son
|
||||
// propre nœud de texte, et rien n'y est interprété comme du balisage.
|
||||
//
|
||||
// Le statut est celui de statuts (src/application/places.js), lu dans
|
||||
// l'instance du moteur ; l'itinéraire, celui d'itineraire (grille.js), lu
|
||||
// tour par tour dans etatPlaces : la fiche ne recalcule ni l'un ni
|
||||
// l'autre, et ne porte aucun contrôle de saisie.
|
||||
//
|
||||
// Ouverte, ou passée à une autre personne, la fiche se fait défiler
|
||||
// jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la
|
||||
// grille.
|
||||
import { getContext } from 'svelte';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import { etatPlaces, statuts } from '../../application/places.js';
|
||||
import { cleDeColonne, cleDeStatut, itineraire, reservationsDe } from './grille.js';
|
||||
|
||||
let { participant, charge } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
|
||||
// Les valeurs de la fiche, sous l'étiquette de leur colonne.
|
||||
const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
|
||||
|
||||
const statut = $derived.by(() => {
|
||||
if (participant.exclu) return t('participants.fiche.statutExclue');
|
||||
const { parId, erreur } = statuts(charge);
|
||||
if (erreur !== null || !parId.has(participant.id)) return t('participants.fiche.statutInconnu');
|
||||
return t(cleDeStatut(parId.get(participant.id)));
|
||||
});
|
||||
const reservations = $derived(reservationsDe(charge, participant.id));
|
||||
const etapes = $derived(itineraire(participant.id, charge.evenement.tours, (tour) => etatPlaces(charge, { tour })));
|
||||
|
||||
let element;
|
||||
$effect(() => {
|
||||
if (participant.id !== null) element.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside bind:this={element} class="fiche" data-fiche={participant.id} aria-label={t('participants.fiche.titre')}>
|
||||
<div class="entete">
|
||||
<h3>{nomAffiche(participant)}</h3>
|
||||
<button type="button" data-commande="fermerFiche" onclick={() => ui.ouvrirFiche(null)}>
|
||||
{t('participants.commande.fermerFiche')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
{#each CHAMPS as cle (cle)}
|
||||
<dt>{t(cleDeColonne(cle))}</dt>
|
||||
<dd>{participant[cle] ?? t('participants.fiche.vide')}</dd>
|
||||
{/each}
|
||||
<dt>{t('participants.fiche.statut')}</dt>
|
||||
<dd>{statut}</dd>
|
||||
</dl>
|
||||
|
||||
<table>
|
||||
<caption>{t('participants.fiche.reservations')}</caption>
|
||||
{#if reservations.length > 0}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('participants.fiche.table')}</th>
|
||||
<th scope="col">{t('participants.fiche.siege')}</th>
|
||||
<th scope="col">{t('participants.fiche.portee')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{/if}
|
||||
<tbody>
|
||||
{#each reservations as reservation, rang (rang)}
|
||||
<tr>
|
||||
<td>{reservation.numero}</td>
|
||||
<td>
|
||||
{#if reservation.siege === null}{t('participants.fiche.sansSiege')}{:else}{reservation.siege}{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if reservation.portee === 'tous'}
|
||||
{t('participants.fiche.tousLesTours')}
|
||||
{:else}
|
||||
<span>{t('participants.fiche.auTour')}</span> <span>{reservation.tour}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="3">{t('participants.fiche.aucuneReservation')}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if participant.exclu && reservations.length > 0}
|
||||
<p>{t('participants.fiche.suspendues')}</p>
|
||||
{/if}
|
||||
|
||||
{#if etapes === null}
|
||||
<p>{t('participants.fiche.aucunRetenu')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<caption>{t('participants.fiche.itineraire')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('participants.fiche.tour')}</th>
|
||||
<th scope="col">{t('participants.fiche.table')}</th>
|
||||
<th scope="col">{t('participants.fiche.siege')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each etapes as etape (etape.tour)}
|
||||
<tr>
|
||||
<td>{etape.tour}</td>
|
||||
{#if etape.reserve}
|
||||
<td colspan="2">{t('participants.fiche.enReserve')}</td>
|
||||
{:else}
|
||||
<td>{etape.numero}</td>
|
||||
<td>
|
||||
{#if etape.siege === null}{t('participants.fiche.siegeNonAttribue')}{:else}{etape.siege}{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.fiche {
|
||||
box-sizing: border-box;
|
||||
width: 24rem;
|
||||
max-width: 100%;
|
||||
padding: var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.entete {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: var(--espace-1) var(--espace-3);
|
||||
margin: var(--espace-3) 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: var(--espace-2) 0;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
caption {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-1);
|
||||
border-bottom: 1px solid var(--couleur-page-filet);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--espace-2) 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
221
src/interface/participants/Formulaire.svelte
Normal file
221
src/interface/participants/Formulaire.svelte
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le formulaire d'ajout d'une personne (§ 4, § 10.2) : un champ par
|
||||
// colonne de texte, seul le nom requis. Entrée, ou « Ajouter », exécute
|
||||
// ajouterParticipant par la séance ; la commande lit chaque saisie — NFC,
|
||||
// blancs de bord retirés, vide valant null — et refuse un nom vide,
|
||||
// NOM_REQUIS, que le formulaire pose contre le champ du nom. Un autre
|
||||
// refus — le mode, l'état du plan — se pose contre « Ajouter ».
|
||||
//
|
||||
// L'appartenance est collante : après un ajout, chaque champ se vide sauf
|
||||
// elle. Une appartenance qui ne diffère d'une existante que par la casse,
|
||||
// les accents ou les blancs (orthographeExistante, grille.js) est signalée
|
||||
// contre son champ, avec l'orthographe existante à reprendre d'un clic ;
|
||||
// rien ne fusionne seul.
|
||||
//
|
||||
// Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est
|
||||
// un geste ouvert (geste.js), qui suspend le compte d'inactivité.
|
||||
import { getContext, onDestroy, tick } from 'svelte';
|
||||
import { cleNormalisee } from '../../application/csv.js';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { cleDeColonne, orthographeExistante } from './grille.js';
|
||||
|
||||
let { participants } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const uid = $props.id();
|
||||
const geste = creerGeste(seance);
|
||||
|
||||
// Les champs du formulaire, dans l'ordre des colonnes de la grille.
|
||||
const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
|
||||
// Ceux qu'un ajout vide : tous, l'appartenance exceptée.
|
||||
const EPHEMERES = CHAMPS.filter((cle) => cle !== 'appartenance');
|
||||
|
||||
let valeurs = $state({ nom: '', prenom: '', appartenance: '', courriel: '', titrePressenti: '', notes: '' });
|
||||
let refusNom = $state.raw(null);
|
||||
let refusAjout = $state.raw(null);
|
||||
let entreeNom = $state(null);
|
||||
|
||||
const orthographe = $derived(orthographeExistante(valeurs.appartenance, participants, cleNormalisee));
|
||||
const idRefusNom = `${uid}-refus-nom`;
|
||||
const idOrthographe = `${uid}-orthographe`;
|
||||
|
||||
$effect(() => {
|
||||
geste.tenir(EPHEMERES.some((cle) => valeurs[cle] !== ''));
|
||||
});
|
||||
|
||||
// Une frappe dans le nom retire le refus qu'il portait.
|
||||
function surSaisieNom() {
|
||||
refusNom = null;
|
||||
}
|
||||
|
||||
async function ajouter(evenement) {
|
||||
evenement.preventDefault();
|
||||
refusNom = null;
|
||||
refusAjout = null;
|
||||
const champs = {
|
||||
nom: valeurs.nom,
|
||||
prenom: valeurs.prenom,
|
||||
appartenance: valeurs.appartenance,
|
||||
courriel: valeurs.courriel,
|
||||
titrePressenti: valeurs.titrePressenti,
|
||||
notes: valeurs.notes,
|
||||
};
|
||||
try {
|
||||
await seance.executer('ajouterParticipant', { champs });
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
const refus = { code: erreur.code, details: erreur.details ?? {} };
|
||||
if (refus.code === 'NOM_REQUIS') refusNom = refus;
|
||||
else refusAjout = refus;
|
||||
return;
|
||||
}
|
||||
valeurs = { nom: '', prenom: '', appartenance: valeurs.appartenance, courriel: '', titrePressenti: '', notes: '' };
|
||||
await tick();
|
||||
entreeNom?.focus();
|
||||
}
|
||||
|
||||
function reprendre() {
|
||||
valeurs.appartenance = orthographe;
|
||||
}
|
||||
|
||||
onDestroy(() => geste.liberer());
|
||||
</script>
|
||||
|
||||
<form aria-labelledby={`${uid}-titre`} onsubmit={ajouter}>
|
||||
<h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3>
|
||||
<div class="champs">
|
||||
{#each CHAMPS as cle (cle)}
|
||||
<div class="champ">
|
||||
<label for={`${uid}-${cle}`}>{t(cleDeColonne(cle))}</label>
|
||||
{#if cle === 'nom'}
|
||||
<input
|
||||
id={`${uid}-${cle}`}
|
||||
type="text"
|
||||
data-champ={cle}
|
||||
bind:value={valeurs.nom}
|
||||
bind:this={entreeNom}
|
||||
aria-invalid={refusNom === null ? undefined : 'true'}
|
||||
aria-describedby={refusNom === null ? undefined : idRefusNom}
|
||||
oninput={surSaisieNom}
|
||||
/>
|
||||
{#if refusNom !== null}
|
||||
<Refus id={idRefusNom} refus={refusNom} lever={() => (refusNom = null)} />
|
||||
{/if}
|
||||
{:else if cle === 'appartenance'}
|
||||
<input
|
||||
id={`${uid}-${cle}`}
|
||||
type="text"
|
||||
data-champ={cle}
|
||||
bind:value={valeurs.appartenance}
|
||||
aria-describedby={orthographe === null ? undefined : idOrthographe}
|
||||
/>
|
||||
{#if orthographe !== null}
|
||||
<div class="signal" id={idOrthographe} role="status" data-avertissement="orthographe">
|
||||
<p><span>{t('participants.formulaire.orthographe')}</span> <span class="donnee">{orthographe}</span></p>
|
||||
<button type="button" data-commande="reprendreOrthographe" onclick={reprendre}>
|
||||
{t('participants.formulaire.reprendre')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<input id={`${uid}-${cle}`} type="text" data-champ={cle} bind:value={valeurs[cle]} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="issue">
|
||||
<button type="submit" data-commande="ajouter">{t('participants.formulaire.ajouter')}</button>
|
||||
{#if refusAjout !== null}
|
||||
<Refus refus={refusAjout} lever={() => (refusAjout = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.champs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
input[aria-invalid='true'] {
|
||||
border-color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.signal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.signal p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.donnee {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.issue {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
443
src/interface/participants/Grille.svelte
Normal file
443
src/interface/participants/Grille.svelte
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La grille éditable des participants (§ 10.2) : une ligne par personne
|
||||
// de lignes, dans l'ordre que lui donne SectionParticipants, une cellule
|
||||
// par colonne de COLONNES (grille.js), puis les commandes de la ligne.
|
||||
//
|
||||
// Au clavier, une seule cellule est dans l'ordre de tabulation, la cellule
|
||||
// active ; flèches, Tab et Maj+Tab la déplacent (celluleSuivante), et Tab
|
||||
// après la dernière, Maj+Tab avant la première, rendent le focus au
|
||||
// navigateur. Entrée ou F2 ouvre l'édition d'une cellule de texte, que la
|
||||
// machine de champ.js tient : Entrée, Tab ou la sortie du champ valident,
|
||||
// Échap rend la valeur enregistrée. Les notes s'éditent dans une zone de
|
||||
// texte, qui garde leurs sauts de ligne ; un champ d'une ligne les
|
||||
// retirerait de toute note retouchée. Une validation qui change la valeur
|
||||
// exécute modifierParticipant par la séance, sur ce seul champ ; une
|
||||
// saisie qui se lit comme la valeur enregistrée — en NFC, sans blancs de
|
||||
// bord — n'exécute rien. Entrée sur la cellule d'exclusion exclut ou
|
||||
// réintègre la personne. Un refus se pose dans la cellule où le geste a eu
|
||||
// lieu, avec son remède : celui de la séance, lu dans etat().refus avant
|
||||
// d'ouvrir l'édition, ou celui de la commande, la saisie gardée.
|
||||
//
|
||||
// L'en-tête trie : un clic sur une colonne la trie croissante, un second
|
||||
// décroissante ; le sens se lit en texte et dans aria-sort. Un collage sur
|
||||
// la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
|
||||
// 10.2) : le texte part à l'état de navigation, intact, et rien ne
|
||||
// s'écrit ici.
|
||||
import { getContext, onDestroy, tick } from 'svelte';
|
||||
import { creerMachineChamp } from '../primitives/champ.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { COLONNES, celluleSuivante, cleDeColonne } from './grille.js';
|
||||
|
||||
let { lignes, tri = $bindable(null), supprimer } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
const geste = creerGeste(seance);
|
||||
|
||||
// La cellule active : l'identifiant de sa ligne et le rang de sa colonne.
|
||||
let actif = $state({ id: null, colonne: 0 });
|
||||
// La cellule en édition, {id, colonne}, et le texte saisi.
|
||||
let edition = $state.raw(null);
|
||||
let saisie = $state('');
|
||||
// Le refus posé sur une cellule : {id, colonne, refus}.
|
||||
let refusCellule = $state.raw(null);
|
||||
let machine = null;
|
||||
let tableau;
|
||||
let entree = $state(null);
|
||||
|
||||
// Le rang de la ligne active ; la première quand l'active n'est plus affichée.
|
||||
const rangActif = $derived.by(() => {
|
||||
const rang = lignes.findIndex(({ id }) => id === actif.id);
|
||||
return rang < 0 ? 0 : rang;
|
||||
});
|
||||
|
||||
const idRefus = (id, colonne) => `${uid}-refus-${id}-${colonne}`;
|
||||
const enEdition = (id, colonne) => edition !== null && edition.id === id && edition.colonne === colonne;
|
||||
const refusDe = (id, colonne) =>
|
||||
refusCellule !== null && refusCellule.id === id && refusCellule.colonne === colonne ? refusCellule.refus : null;
|
||||
|
||||
// Le texte d'une valeur enregistrée, vide pour null ; et la valeur que
|
||||
// l'enregistrement en lirait, pour reconnaître une saisie sans changement.
|
||||
const texteDe = (valeur) => (valeur === null ? '' : valeur);
|
||||
const lire = (texte) => ({ valeur: texte.normalize('NFC').trim() });
|
||||
|
||||
function trier(colonne) {
|
||||
tri = tri !== null && tri.colonne === colonne && tri.sens === 'croissant' ? { colonne, sens: 'decroissant' } : { colonne, sens: 'croissant' };
|
||||
}
|
||||
|
||||
const ariaSort = (colonne) => {
|
||||
if (tri === null || tri.colonne !== colonne) return 'none';
|
||||
return tri.sens === 'croissant' ? 'ascending' : 'descending';
|
||||
};
|
||||
|
||||
function cellule(id, colonne) {
|
||||
return tableau?.querySelector(`[data-participant="${id}"] [data-champ="${colonne}"]`) ?? null;
|
||||
}
|
||||
|
||||
async function activer(rang, colonne) {
|
||||
const ligne = lignes[rang];
|
||||
actif = { id: ligne.id, colonne };
|
||||
await tick();
|
||||
cellule(ligne.id, COLONNES[colonne])?.focus();
|
||||
}
|
||||
|
||||
function refuser(id, colonne, erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
refusCellule = { id, colonne, refus: { code: erreur.code, details: erreur.details ?? {} } };
|
||||
}
|
||||
|
||||
async function ouvrirEdition(personne, colonne) {
|
||||
const courant = seance.etat().refus;
|
||||
if (courant !== null) {
|
||||
refusCellule = { id: personne.id, colonne, refus: courant };
|
||||
return;
|
||||
}
|
||||
refusCellule = null;
|
||||
machine = creerMachineChamp({ stockee: texteDe(personne[colonne]), lire });
|
||||
saisie = machine.saisie;
|
||||
edition = { id: personne.id, colonne };
|
||||
await tick();
|
||||
entree?.focus();
|
||||
entree?.setSelectionRange(saisie.length, saisie.length);
|
||||
}
|
||||
|
||||
// Ferme l'édition et rend le focus à sa cellule quand il était dans le
|
||||
// champ.
|
||||
async function fermerEdition() {
|
||||
const fermee = edition;
|
||||
const avaitLeFocus = entree !== null && document.activeElement === entree;
|
||||
edition = null;
|
||||
machine = null;
|
||||
geste.tenir(false);
|
||||
if (fermee === null || !avaitLeFocus) return;
|
||||
await tick();
|
||||
cellule(fermee.id, fermee.colonne)?.focus();
|
||||
}
|
||||
|
||||
// Valide la saisie de la cellule en édition : rien quand elle se lit comme
|
||||
// la valeur enregistrée, la commande sinon. Refusée, l'édition reste
|
||||
// ouverte sur la saisie, le refus contre elle.
|
||||
function valider() {
|
||||
if (edition === null) return null;
|
||||
const { id, colonne } = edition;
|
||||
const rendu = machine.valider();
|
||||
saisie = machine.saisie;
|
||||
if (rendu.action !== 'executer') return fermerEdition();
|
||||
const enCours = machine;
|
||||
let promesse;
|
||||
try {
|
||||
promesse = seance.executer('modifierParticipant', { id, champs: { [colonne]: rendu.valeur } });
|
||||
} catch (erreur) {
|
||||
return echouer(enCours, id, colonne, erreur);
|
||||
}
|
||||
fermerEdition();
|
||||
return Promise.resolve(promesse).catch((erreur) => echouer(enCours, id, colonne, erreur));
|
||||
}
|
||||
|
||||
async function echouer(enCours, id, colonne, erreur) {
|
||||
refuser(id, colonne, erreur);
|
||||
enCours.echec(refusCellule.refus);
|
||||
machine = enCours;
|
||||
saisie = enCours.saisie;
|
||||
edition = { id, colonne };
|
||||
geste.tenir(enCours.enCours);
|
||||
await tick();
|
||||
entree?.focus();
|
||||
}
|
||||
|
||||
function abandonner() {
|
||||
machine?.abandonner();
|
||||
refusCellule = null;
|
||||
return fermerEdition();
|
||||
}
|
||||
|
||||
function surSaisie(evenement) {
|
||||
machine.saisir(evenement.currentTarget.value);
|
||||
saisie = machine.saisie;
|
||||
refusCellule = null;
|
||||
geste.tenir(machine.enCours);
|
||||
}
|
||||
|
||||
async function surToucheEntree(evenement, rang) {
|
||||
const touche = evenement.key;
|
||||
if (touche === 'Escape') {
|
||||
evenement.preventDefault();
|
||||
abandonner();
|
||||
} else if (touche === 'Enter') {
|
||||
evenement.preventDefault();
|
||||
valider();
|
||||
} else if (touche === 'Tab') {
|
||||
evenement.preventDefault();
|
||||
const { id } = edition;
|
||||
const colonne = COLONNES.indexOf(edition.colonne);
|
||||
const sens = evenement.shiftKey ? 'Maj+Tab' : 'Tab';
|
||||
await valider();
|
||||
await tick();
|
||||
tabulerDepuis(id, rang, colonne, sens);
|
||||
}
|
||||
}
|
||||
|
||||
// Tab ou Maj+Tab depuis la cellule validée de la personne id, au rang
|
||||
// rangAvant avant la validation. La ligne se relit par son identifiant :
|
||||
// la valeur validée peut l'avoir déplacée au tri, et le rang d'avant
|
||||
// désignerait une autre personne. Une ligne que le filtre ne montre plus a
|
||||
// laissé son rang à celle qui la suivait, les autres gardant leur ordre :
|
||||
// Tab mène à la première cellule de celle-ci, Maj+Tab à la dernière de la
|
||||
// ligne d'avant ; hors de la grille, le focus reste où il est.
|
||||
function tabulerDepuis(id, rangAvant, colonne, touche) {
|
||||
const rang = lignes.findIndex((personne) => personne.id === id);
|
||||
if (rang >= 0) return deplacer(rang, colonne, touche);
|
||||
const suivante = touche === 'Tab' ? { ligne: rangAvant, colonne: 0 } : { ligne: rangAvant - 1, colonne: COLONNES.length - 1 };
|
||||
if (suivante.ligne < 0 || suivante.ligne >= lignes.length) return false;
|
||||
activer(suivante.ligne, suivante.colonne);
|
||||
return true;
|
||||
}
|
||||
|
||||
// La sortie du champ valide, sauf quand l'édition s'est déjà close.
|
||||
function surSortie() {
|
||||
if (edition !== null) valider();
|
||||
}
|
||||
|
||||
function deplacer(rang, colonne, touche) {
|
||||
const suivante = celluleSuivante({ ligne: rang, colonne }, touche, { lignes: lignes.length, colonnes: COLONNES.length });
|
||||
if (suivante === null) return false;
|
||||
activer(suivante.ligne, suivante.colonne);
|
||||
return true;
|
||||
}
|
||||
|
||||
function basculerExclusion(personne) {
|
||||
const nom = personne.exclu ? 'reintegrerParticipant' : 'exclureParticipant';
|
||||
refusCellule = null;
|
||||
try {
|
||||
Promise.resolve(seance.executer(nom, { id: personne.id })).catch((erreur) => refuser(personne.id, 'exclu', erreur));
|
||||
} catch (erreur) {
|
||||
refuser(personne.id, 'exclu', erreur);
|
||||
}
|
||||
}
|
||||
|
||||
function surToucheCellule(evenement, personne, rang, colonne) {
|
||||
if (evenement.target !== evenement.currentTarget) return;
|
||||
const touche = evenement.key === 'Tab' && evenement.shiftKey ? 'Maj+Tab' : evenement.key;
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab', 'Maj+Tab'].includes(touche)) {
|
||||
if (deplacer(rang, colonne, touche)) evenement.preventDefault();
|
||||
} else if (touche === 'Enter' || touche === 'F2') {
|
||||
evenement.preventDefault();
|
||||
if (COLONNES[colonne] === 'exclu') basculerExclusion(personne);
|
||||
else ouvrirEdition(personne, COLONNES[colonne]);
|
||||
} else if (touche === 'Escape') {
|
||||
refusCellule = null;
|
||||
}
|
||||
}
|
||||
|
||||
function surCollage(evenement) {
|
||||
if (evenement.target instanceof HTMLInputElement || evenement.target instanceof HTMLTextAreaElement) return;
|
||||
const texte = evenement.clipboardData?.getData('text/plain') ?? '';
|
||||
if (texte === '') return;
|
||||
evenement.preventDefault();
|
||||
ui.ouvrirImport({ texte, origine: 'collage' });
|
||||
ui.allerA('import');
|
||||
}
|
||||
|
||||
onDestroy(() => geste.liberer());
|
||||
</script>
|
||||
|
||||
<div class="defilement">
|
||||
<table
|
||||
role="grid"
|
||||
aria-label={t('participants.grille.titre')}
|
||||
tabindex={lignes.length === 0 ? 0 : undefined}
|
||||
bind:this={tableau}
|
||||
onpaste={surCollage}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{#each COLONNES as colonne (colonne)}
|
||||
<th scope="col" aria-sort={ariaSort(colonne)}>
|
||||
<button type="button" data-commande="trier" data-cible={colonne} onclick={() => trier(colonne)}>
|
||||
<span>{t(cleDeColonne(colonne))}</span>
|
||||
{#if tri !== null && tri.colonne === colonne}
|
||||
<span class="sens">{t(tri.sens === 'croissant' ? 'participants.grille.croissant' : 'participants.grille.decroissant')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</th>
|
||||
{/each}
|
||||
<th scope="col">{t('participants.grille.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each lignes as personne, rang (personne.id)}
|
||||
<tr data-participant={personne.id}>
|
||||
{#each COLONNES as colonne, rangColonne (colonne)}
|
||||
{@const refus = refusDe(personne.id, colonne)}
|
||||
<td
|
||||
role="gridcell"
|
||||
data-champ={colonne}
|
||||
tabindex={rang === rangActif && rangColonne === actif.colonne ? 0 : -1}
|
||||
aria-describedby={refus === null || enEdition(personne.id, colonne) ? undefined : idRefus(personne.id, colonne)}
|
||||
onfocus={(evenement) => {
|
||||
if (evenement.target === evenement.currentTarget) actif = { id: personne.id, colonne: rangColonne };
|
||||
}}
|
||||
onkeydown={(evenement) => surToucheCellule(evenement, personne, rang, rangColonne)}
|
||||
>
|
||||
{#if enEdition(personne.id, colonne) && colonne === 'notes'}
|
||||
<textarea
|
||||
rows="3"
|
||||
aria-label={t(cleDeColonne(colonne))}
|
||||
value={saisie}
|
||||
bind:this={entree}
|
||||
aria-invalid={refus === null ? undefined : 'true'}
|
||||
aria-describedby={refus === null ? undefined : idRefus(personne.id, colonne)}
|
||||
oninput={surSaisie}
|
||||
onkeydown={(evenement) => surToucheEntree(evenement, rang)}
|
||||
onblur={surSortie}
|
||||
></textarea>
|
||||
{:else if enEdition(personne.id, colonne)}
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t(cleDeColonne(colonne))}
|
||||
value={saisie}
|
||||
bind:this={entree}
|
||||
aria-invalid={refus === null ? undefined : 'true'}
|
||||
aria-describedby={refus === null ? undefined : idRefus(personne.id, colonne)}
|
||||
oninput={surSaisie}
|
||||
onkeydown={(evenement) => surToucheEntree(evenement, rang)}
|
||||
onblur={surSortie}
|
||||
/>
|
||||
{:else if colonne === 'exclu'}
|
||||
<span class:exclue={personne.exclu}>
|
||||
{t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="valeur">{personne[colonne] ?? ''}</span>
|
||||
{/if}
|
||||
{#if refus !== null}
|
||||
<Refus id={idRefus(personne.id, colonne)} {refus} lever={() => (refusCellule = null)} />
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
<td role="gridcell">
|
||||
<div class="commandes">
|
||||
<button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}>
|
||||
{t('participants.commande.fiche')}
|
||||
</button>
|
||||
{#if personne.exclu}
|
||||
<Commande
|
||||
cle="participants.commande.reintegrer"
|
||||
nom="reintegrerParticipant"
|
||||
executer={() => seance.executer('reintegrerParticipant', { id: personne.id })}
|
||||
/>
|
||||
{:else}
|
||||
<Commande
|
||||
cle="participants.commande.exclure"
|
||||
nom="exclureParticipant"
|
||||
executer={() => seance.executer('exclureParticipant', { id: personne.id })}
|
||||
/>
|
||||
{/if}
|
||||
<Commande cle="participants.commande.supprimer" nom="supprimerParticipant" executer={() => supprimer(personne)} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.defilement {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
td {
|
||||
max-width: 18rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.valeur {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* L'exclusion se lit en texte ; l'italique la double, sans couleur. */
|
||||
.exclue {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
th button {
|
||||
display: inline-flex;
|
||||
gap: var(--espace-1);
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sens {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.commandes > button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--espace-1);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[aria-invalid='true'],
|
||||
textarea[aria-invalid='true'] {
|
||||
border-color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
td:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
table:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
742
src/interface/participants/Participants.navigateur.test.js
Normal file
742
src/interface/participants/Participants.navigateur.test.js
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La liste des participants dans un vrai moteur de rendu (§ 4, § 4.2, § 4.4,
|
||||
// § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance
|
||||
// collante, la grille au clavier, le tri, le filtre et le compte,
|
||||
// l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche
|
||||
// et l'itinéraire, l'export et le collage. Chaque épreuve monte App par
|
||||
// monterApp, sur le système de fichiers simulé et l'horloge pilotée ; chaque
|
||||
// écriture se relève (trace) et chaque entrée se compte dans le journal. Les
|
||||
// gestes passent par des entrées que Chromium traite comme réelles
|
||||
// (test/navigateur/entrees.js). Les noms d'épreuve sont inventés.
|
||||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { cdp } from 'vitest/browser';
|
||||
import { flushSync, tick } from 'svelte';
|
||||
import { bilanChangementDefaut } from '../../application/bilans.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { apercevoir, decoder, exporterParticipants } from '../../application/csv.js';
|
||||
import { STATUT, nombrePlacesManquantes, normaliser } from '../../moteur/configuration.js';
|
||||
import { configurationDepuisCharge } from '../../stockage/document.js';
|
||||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { formeNommee } from '../../stockage/placements.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { visible } from '../../../test/navigateur/rendu.js';
|
||||
import CAS_LIMITES from '../../../exemples/participants_cas_limites.csv?url';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { COLONNES, cleDeStatut } from './grille.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
const EVENEMENT = 'Soirée des Merles';
|
||||
const CLUB = 'Club des Merles';
|
||||
|
||||
// Des noms inventés, hors du réservoir du générateur.
|
||||
const NOMS = [
|
||||
'Quenouillet',
|
||||
'Brindelle',
|
||||
'Calvaille',
|
||||
'Doucemer',
|
||||
'Esquirol',
|
||||
'Faverolle',
|
||||
'Glanduc',
|
||||
'Houblonnet',
|
||||
'Isardin',
|
||||
'Joncaille',
|
||||
'Kerbouet',
|
||||
'Lisonnet',
|
||||
'Moucheron',
|
||||
'Nivelette',
|
||||
'Orbelin',
|
||||
'Pistachon',
|
||||
'Rouvrelle',
|
||||
];
|
||||
|
||||
// Les montages de l'épreuve en cours, démontés après elle ; le collecteur
|
||||
// n'a rien reçu pendant l'épreuve.
|
||||
let montes = [];
|
||||
let erreursAvant = 0;
|
||||
beforeEach(() => {
|
||||
erreursAvant = globalThis.__gttErreurs?.length ?? 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
vi.restoreAllMocks();
|
||||
const recues = globalThis.__gttErreurs?.slice(erreursAvant) ?? [];
|
||||
expect(recues, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
async function rendu() {
|
||||
flushSync();
|
||||
await tick();
|
||||
}
|
||||
|
||||
// Attend que la file de la séance ait passé ce que le geste y a posé, par
|
||||
// une lecture admise dans tout mode qui n'écrit rien, puis le rendu.
|
||||
async function attendreSeance(m) {
|
||||
await m.seance.formeNommee(null).catch(() => undefined);
|
||||
await rendu();
|
||||
await rendu();
|
||||
}
|
||||
|
||||
// Un CSV d'épreuve, séparé par « ; » : nom, prénom, appartenance, exclu,
|
||||
// titre pressenti, notes.
|
||||
const csv = (personnes) =>
|
||||
[
|
||||
'nom;prenom;appartenance;exclu;titre_pressenti;notes',
|
||||
...personnes.map((p) => [p.nom, p.prenom ?? '', p.appartenance ?? '', p.exclu ? 'oui' : '', p.titre ?? '', p.notes ?? ''].join(';')),
|
||||
].join('\n');
|
||||
|
||||
// Importe un texte par la commande, comme le ferait l'aperçu (I6).
|
||||
const importer = (m, texte) => m.seance.executer('importerParticipants', { apercu: apercevoir(texte), mode: 'ajouter' });
|
||||
|
||||
// Les octets d'un CSV livré, lus par son adresse.
|
||||
async function octetsLivres(url) {
|
||||
const reponse = await fetch(url);
|
||||
return new Uint8Array(await reponse.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Un montage, un événement créé — sieges places par défaut, tours tours,
|
||||
* tables tables rondes, personnes importées —, en écriture, la section des
|
||||
* participants ouverte.
|
||||
*/
|
||||
async function evenementOuvert({ sieges = 8, tours = 2, tables = 0, personnes = [] } = {}) {
|
||||
const m = await monterApp();
|
||||
montes.push(m);
|
||||
await m.seance.creer({ nom: EVENEMENT, date: null, siegesParDefaut: sieges, tours });
|
||||
await m.seance.passerEnEcriture();
|
||||
for (let i = 0; i < tables; i += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
|
||||
if (personnes.length > 0) await importer(m, csv(personnes));
|
||||
m.ui.allerA('participants');
|
||||
await rendu();
|
||||
return m;
|
||||
}
|
||||
|
||||
const base = (m) => m.seance.etat().evenement.base;
|
||||
const texteJournal = (m) => m.fs.contenu('documents', `${base(m)}${SUFFIXES.journal}`);
|
||||
const entrees = (m) => lireJournal(texteJournal(m)).entrees;
|
||||
const texteEtat = (m) => m.fs.contenu('documents', `${base(m)}${SUFFIXES.etat}`);
|
||||
const participants = (m) => m.seance.etat().charge.participants;
|
||||
const idDe = (m, nom) => participants(m).find((p) => p.nom === nom).id;
|
||||
|
||||
const section = (m) => m.racine.querySelector('[data-section="participants"]');
|
||||
const formulaire = (m) => section(m).querySelector('form');
|
||||
const champ = (m, cle) => formulaire(m).querySelector(`[data-champ="${cle}"]`);
|
||||
const grille = (m) => section(m).querySelector('[role="grid"]');
|
||||
const lignes = (m) => [...grille(m).querySelectorAll('[data-participant]')];
|
||||
const idsAffiches = (m) => lignes(m).map((ligne) => Number(ligne.dataset.participant));
|
||||
const ligne = (m, id) => grille(m).querySelector(`[data-participant="${id}"]`);
|
||||
const cellule = (m, id, colonne) => ligne(m, id).querySelector(`[data-champ="${colonne}"]`);
|
||||
const commandeDeLigne = (m, id, nom) => ligne(m, id).querySelector(`[data-commande="${nom}"]`);
|
||||
const fiche = (m) => section(m).querySelector('[data-fiche]');
|
||||
|
||||
const centre = (element) => {
|
||||
const b = element.getBoundingClientRect();
|
||||
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
|
||||
};
|
||||
|
||||
// Un clic réel au centre de l'élément, amené d'abord dans la fenêtre.
|
||||
async function cliquer(element) {
|
||||
element.scrollIntoView({ block: 'center' });
|
||||
const { x, y } = centre(element);
|
||||
await pointer('presser', x, y);
|
||||
await pointer('relacher', x, y);
|
||||
await rendu();
|
||||
}
|
||||
|
||||
// Remplace le texte d'un champ par une saisie réelle : le champ prend le
|
||||
// focus, son texte est sélectionné, puis remplacé par l'insertion du texte,
|
||||
// ou effacé.
|
||||
async function saisir(entree, texte) {
|
||||
entree.focus();
|
||||
entree.select();
|
||||
if (texte === '') await touche('Backspace');
|
||||
else await cdp().send('Input.insertText', { text: texte });
|
||||
await rendu();
|
||||
}
|
||||
|
||||
// Ajoute une personne par le formulaire : chaque champ donné saisi, puis
|
||||
// Entrée dans le champ du nom.
|
||||
async function ajouter(m, champs) {
|
||||
for (const [cle, valeur] of Object.entries(champs)) await saisir(champ(m, cle), valeur);
|
||||
champ(m, 'nom').focus();
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
}
|
||||
|
||||
// Maj+Tab réel : touche() ne porte pas de modificateur ; 8 est le masque de
|
||||
// Maj dans Input.dispatchKeyEvent.
|
||||
async function majTab() {
|
||||
const commun = { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9, nativeVirtualKeyCode: 9, modifiers: 8 };
|
||||
await cdp().send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...commun });
|
||||
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
|
||||
}
|
||||
|
||||
// La valeur de la fiche que nomme l'étiquette de clé donnée.
|
||||
function valeurDeFiche(m, cle) {
|
||||
const etiquette = [...fiche(m).querySelectorAll('dt')].find((dt) => dt.textContent === t(cle));
|
||||
expect(etiquette, cle).toBeDefined();
|
||||
return etiquette.nextElementSibling;
|
||||
}
|
||||
|
||||
// Le tableau de la fiche que titre la légende de clé donnée.
|
||||
const tableauDeFiche = (m, cle) => [...fiche(m).querySelectorAll('table')].find((tableau) => tableau.caption?.textContent === t(cle));
|
||||
|
||||
// L'écart entre deux boîtes : 0 quand elles se touchent ou se chevauchent.
|
||||
function ecart(a, b) {
|
||||
const ba = a.getBoundingClientRect();
|
||||
const bb = b.getBoundingClientRect();
|
||||
const dx = Math.max(0, ba.left - bb.right, bb.left - ba.right);
|
||||
const dy = Math.max(0, ba.top - bb.bottom, bb.top - ba.bottom);
|
||||
return Math.max(dx, dy);
|
||||
}
|
||||
|
||||
const aucuneFenetre = () => expect(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog]').length).toBe(0);
|
||||
|
||||
describe('la saisie (§ 4, § 10.2)', () => {
|
||||
test('seul le nom : « Pervenche » enregistre une personne aux autres champs null, en une entrée ; un nom tapé en NFD est enregistré en NFC', async () => {
|
||||
const m = await evenementOuvert();
|
||||
const avant = entrees(m).length;
|
||||
await ajouter(m, { nom: 'Pervenche' });
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
const [{ id, ...champs }] = participants(m);
|
||||
expect(Number.isInteger(id)).toBe(true);
|
||||
expect(champs).toEqual({
|
||||
nom: 'Pervenche',
|
||||
prenom: null,
|
||||
appartenance: null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
});
|
||||
expect(champ(m, 'nom').value).toBe('');
|
||||
|
||||
const decompose = 'Ombre\u{0301}lle';
|
||||
expect(decompose.length).toBe(9);
|
||||
await ajouter(m, { nom: decompose });
|
||||
expect(entrees(m)).toHaveLength(avant + 2);
|
||||
expect(participants(m).at(-1).nom).toBe('Ombr\u{00E9}lle');
|
||||
});
|
||||
|
||||
test('nom vide : « ␠␠ » pose sur le champ aria-invalid et un message visible qui le jouxte ; aucune fenêtre, zéro écriture', async () => {
|
||||
const m = await evenementOuvert();
|
||||
const trace = m.trace.length;
|
||||
const etat = texteEtat(m);
|
||||
await ajouter(m, { nom: ' ' });
|
||||
const entree = champ(m, 'nom');
|
||||
expect(entree.getAttribute('aria-invalid')).toBe('true');
|
||||
const message = document.getElementById(entree.getAttribute('aria-describedby'));
|
||||
expect(message).not.toBeNull();
|
||||
expect(message.textContent).toContain(t('NOM_REQUIS', {}));
|
||||
expect(visible(message)).toBe(true);
|
||||
expect(ecart(entree, message)).toBeLessThanOrEqual(16);
|
||||
aucuneFenetre();
|
||||
expect(m.trace.length).toBe(trace);
|
||||
expect(texteEtat(m)).toBe(etat);
|
||||
expect(participants(m)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('appartenance collante : trois personnes portent « Club des Merles » ; le champ vidé, la quatrième n’en porte aucune', async () => {
|
||||
const m = await evenementOuvert();
|
||||
await ajouter(m, { nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB });
|
||||
expect(champ(m, 'appartenance').value).toBe(CLUB);
|
||||
expect(champ(m, 'prenom').value).toBe('');
|
||||
await ajouter(m, { nom: 'Pervenche' });
|
||||
await ajouter(m, { nom: 'Lacasse' });
|
||||
expect(participants(m).map((p) => [p.nom, p.prenom, p.appartenance])).toEqual([
|
||||
['Ombrelle', 'Iris', CLUB],
|
||||
['Pervenche', null, CLUB],
|
||||
['Lacasse', null, CLUB],
|
||||
]);
|
||||
await saisir(champ(m, 'appartenance'), '');
|
||||
await ajouter(m, { nom: 'Grisaille' });
|
||||
expect(participants(m).at(-1).appartenance).toBeNull();
|
||||
});
|
||||
|
||||
test('une appartenance qui ne diffère d’une existante que par la casse et les blancs est signalée sur le champ, l’orthographe existante à reprendre ; rien ne fusionne seul', async () => {
|
||||
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }] });
|
||||
const trace = m.trace.length;
|
||||
await saisir(champ(m, 'appartenance'), 'club des merles');
|
||||
const signal = formulaire(m).querySelector('[data-avertissement]');
|
||||
expect(signal).not.toBeNull();
|
||||
expect(visible(signal)).toBe(true);
|
||||
expect(signal.textContent).toContain(CLUB);
|
||||
expect(champ(m, 'appartenance').getAttribute('aria-describedby')).toContain(signal.id);
|
||||
expect(champ(m, 'appartenance').value).toBe('club des merles');
|
||||
await cliquer(signal.querySelector('[data-commande="reprendreOrthographe"]'));
|
||||
expect(champ(m, 'appartenance').value).toBe(CLUB);
|
||||
expect(formulaire(m).querySelector('[data-avertissement]')).toBeNull();
|
||||
expect(m.trace.length).toBe(trace);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la grille (§ 10.2, § 19.3)', () => {
|
||||
const TROIS = [
|
||||
{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB },
|
||||
{ nom: 'Pervenche', prenom: 'Théo', appartenance: 'CLUB DES MERLES' },
|
||||
{ nom: 'Lacasse', prenom: 'Ondine' },
|
||||
{ nom: 'Grisaille' },
|
||||
];
|
||||
|
||||
test('au clavier seul : le courriel d’Ondine Lacasse changé en une entrée ; Échap rend la valeur, valider sans changement n’écrit rien', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS });
|
||||
const ondine = idDe(m, 'Lacasse');
|
||||
const rang = idsAffiches(m).indexOf(ondine);
|
||||
const colonne = COLONNES.indexOf('courriel');
|
||||
expect(rang).toBeGreaterThan(0);
|
||||
expect(colonne).toBeGreaterThan(0);
|
||||
|
||||
const premiere = grille(m).querySelector('[tabindex="0"]');
|
||||
expect(premiere).toBe(cellule(m, idsAffiches(m)[0], COLONNES[0]));
|
||||
premiere.focus();
|
||||
for (let i = 0; i < rang; i += 1) await touche('ArrowDown');
|
||||
for (let i = 0; i < colonne; i += 1) await touche('ArrowRight');
|
||||
await rendu();
|
||||
const cible = cellule(m, ondine, 'courriel');
|
||||
expect(document.activeElement).toBe(cible);
|
||||
|
||||
const avant = entrees(m).length;
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
for (const caractere of 'ondine@exemple.test') await touche(caractere);
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(entrees(m).at(-1).libelle).toBe('Modifié Ondine Lacasse : courriel');
|
||||
expect(participants(m).find((p) => p.id === ondine).courriel).toBe('ondine@exemple.test');
|
||||
expect(cellule(m, ondine, 'courriel').textContent.trim()).toBe('ondine@exemple.test');
|
||||
|
||||
// Échap en cours d'édition : rien ne s'écrit, la valeur d'origine revient.
|
||||
const trace = m.trace.length;
|
||||
cellule(m, ondine, 'courriel').focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
for (const caractere of 'zz') await touche(caractere);
|
||||
await touche('Escape');
|
||||
await attendreSeance(m);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(cellule(m, ondine, 'courriel').querySelector('input')).toBeNull();
|
||||
expect(cellule(m, ondine, 'courriel').textContent.trim()).toBe('ondine@exemple.test');
|
||||
|
||||
// Valider sans changement : aucune commande ne part, donc aucun refus
|
||||
// SANS_EFFET ne se pose sur la cellule, et rien ne s'écrit.
|
||||
const executer = vi.spyOn(m.seance, 'executer');
|
||||
cellule(m, ondine, 'courriel').focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
expect(executer).not.toHaveBeenCalled();
|
||||
expect(cellule(m, ondine, 'courriel').querySelector('[data-refus]')).toBeNull();
|
||||
expect(cellule(m, ondine, 'courriel').querySelector('[aria-invalid]')).toBeNull();
|
||||
expect(cellule(m, ondine, 'courriel').querySelector('input')).toBeNull();
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(m.trace.length).toBe(trace);
|
||||
});
|
||||
|
||||
test('Tab après une saisie qui déplace la ligne au tri mène à la cellule suivante de la même personne', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS });
|
||||
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
|
||||
const premier = idsAffiches(m)[0];
|
||||
expect(participants(m).find((p) => p.id === premier).nom).toBe('Grisaille');
|
||||
|
||||
cellule(m, premier, 'nom').focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
await saisir(cellule(m, premier, 'nom').querySelector('input'), 'Zed');
|
||||
await touche('Tab');
|
||||
await attendreSeance(m);
|
||||
expect(participants(m).find((p) => p.id === premier).nom).toBe('Zed');
|
||||
expect(idsAffiches(m).at(-1)).toBe(premier);
|
||||
expect(document.activeElement).toBe(cellule(m, premier, COLONNES[1]));
|
||||
});
|
||||
|
||||
test('Tab après une saisie qui retire la ligne du filtre mène à la première cellule de la personne qui la suivait', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS });
|
||||
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
|
||||
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
|
||||
const [premier, second] = idsAffiches(m);
|
||||
expect(idsAffiches(m)).toHaveLength(2);
|
||||
|
||||
cellule(m, premier, 'appartenance').focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
await saisir(cellule(m, premier, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
|
||||
await touche('Tab');
|
||||
await attendreSeance(m);
|
||||
expect(idsAffiches(m)).toEqual([second]);
|
||||
expect(document.activeElement).toBe(cellule(m, second, COLONNES[0]));
|
||||
});
|
||||
|
||||
test('Maj+Tab après une saisie qui retire la ligne du filtre mène à la dernière cellule de la personne qui la précédait', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS });
|
||||
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
|
||||
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
|
||||
const [premier, second] = idsAffiches(m);
|
||||
expect(idsAffiches(m)).toHaveLength(2);
|
||||
|
||||
cellule(m, second, 'appartenance').focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
await saisir(cellule(m, second, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
|
||||
await majTab();
|
||||
await attendreSeance(m);
|
||||
expect(idsAffiches(m)).toEqual([premier]);
|
||||
expect(document.activeElement).toBe(cellule(m, premier, COLONNES.at(-1)));
|
||||
});
|
||||
|
||||
test('une note de deux lignes, retouchée dans la grille, garde son saut de ligne', async () => {
|
||||
const m = await evenementOuvert();
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine', notes: 'arrive tard\nrepart tôt' } });
|
||||
await rendu();
|
||||
const id = idDe(m, 'Lacasse');
|
||||
const avant = entrees(m).length;
|
||||
cellule(m, id, 'notes').focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
for (const caractere of ' x') await touche(caractere);
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(participants(m).find((p) => p.id === id).notes).toBe('arrive tard\nrepart tôt x');
|
||||
});
|
||||
|
||||
test('trier par nom, croissant puis décroissant, filtrer « merles » : l’ordre et les lignes suivent ; rien ne s’écrit, en écriture comme en lecture', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS });
|
||||
const parNom = (sens) =>
|
||||
[...participants(m)]
|
||||
.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : a.id - b.id) * (sens === 'croissant' ? 1 : -1))
|
||||
.map((p) => p.id);
|
||||
const merles = participants(m)
|
||||
.filter((p) => p.appartenance !== null)
|
||||
.map((p) => p.id);
|
||||
expect(merles).toHaveLength(2);
|
||||
|
||||
for (const mode of ['ecriture', 'lecture']) {
|
||||
if (mode === 'lecture') {
|
||||
await m.seance.passerEnLecture();
|
||||
await rendu();
|
||||
}
|
||||
const journal = texteJournal(m);
|
||||
const etat = texteEtat(m);
|
||||
const trace = m.trace.length;
|
||||
const trier = section(m).querySelector('[data-commande="trier"][data-cible="nom"]');
|
||||
await cliquer(trier);
|
||||
expect(idsAffiches(m), mode).toEqual(parNom('croissant'));
|
||||
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
|
||||
expect(idsAffiches(m), mode).toEqual(parNom('decroissant'));
|
||||
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
|
||||
expect([...idsAffiches(m)].sort((a, b) => a - b), mode).toEqual(merles);
|
||||
expect(section(m).querySelector('output').textContent).toBe(t('participants.grille.compte', { total: 4, affiches: 2 }));
|
||||
await saisir(section(m).querySelector('[data-champ="filtre"]'), '');
|
||||
expect(idsAffiches(m)).toHaveLength(4);
|
||||
await attendreSeance(m);
|
||||
expect(texteJournal(m), mode).toBe(journal);
|
||||
expect(texteEtat(m), mode).toBe(etat);
|
||||
expect(m.trace.length, mode).toBe(trace);
|
||||
}
|
||||
});
|
||||
|
||||
test('après l’import des cas limites, le compte affiché égale le nombre de lignes importées ; une personne exclue reste visible', async () => {
|
||||
const m = await evenementOuvert();
|
||||
const octets = await octetsLivres(CAS_LIMITES);
|
||||
const { resume } = await m.seance.executer('importerParticipants', {
|
||||
apercu: apercevoir(decoder(octets).texte),
|
||||
mode: 'ajouter',
|
||||
});
|
||||
await rendu();
|
||||
expect(resume.ajoutes).toBeGreaterThan(0);
|
||||
expect(participants(m)).toHaveLength(resume.ajoutes);
|
||||
expect(lignes(m)).toHaveLength(resume.ajoutes);
|
||||
expect(section(m).querySelector('output').textContent).toBe(
|
||||
t('participants.grille.compte', { total: resume.ajoutes, affiches: resume.ajoutes }),
|
||||
);
|
||||
const exclue = participants(m).find((p) => p.exclu);
|
||||
expect(exclue).toBeDefined();
|
||||
expect(cellule(m, exclue.id, 'exclu').textContent.trim()).toBe(t('participants.grille.exclue'));
|
||||
});
|
||||
|
||||
test('exclusion : douze personnes, en exclure une → douze lignes, la sienne porte « exclue » en texte ; le bilan compte onze participants', async () => {
|
||||
const m = await evenementOuvert({ personnes: NOMS.slice(0, 12).map((nom) => ({ nom })) });
|
||||
const id = idDe(m, NOMS[3]);
|
||||
const avant = entrees(m).length;
|
||||
await cliquer(commandeDeLigne(m, id, 'exclureParticipant'));
|
||||
await attendreSeance(m);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(lignes(m)).toHaveLength(12);
|
||||
expect(cellule(m, id, 'exclu').textContent.trim()).toBe(t('participants.grille.exclue'));
|
||||
const autres = participants(m).filter((p) => p.id !== id);
|
||||
expect(autres).toHaveLength(11);
|
||||
// La marque est un texte, distinct de celui d'une personne présente.
|
||||
for (const p of autres) expect(cellule(m, p.id, 'exclu').textContent.trim()).toBe(t('participants.grille.presente'));
|
||||
expect(t('participants.grille.presente')).not.toBe(t('participants.grille.exclue'));
|
||||
expect(bilanChangementDefaut(m.seance.etat().charge, 8).participants).toBe(11);
|
||||
});
|
||||
|
||||
test('notes jamais interprétées : « <b>x</b> & =1+1 » s’affiche tel quel dans la grille et sur la fiche, sans élément <b>', async () => {
|
||||
const m = await evenementOuvert();
|
||||
const notes = '<b>x</b> & =1+1';
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', notes } });
|
||||
await rendu();
|
||||
const id = idDe(m, 'Ombrelle');
|
||||
expect(cellule(m, id, 'notes').textContent.trim()).toBe(notes);
|
||||
m.ui.ouvrirFiche(id);
|
||||
await rendu();
|
||||
expect(valeurDeFiche(m, 'participants.colonne.notes').textContent).toBe(notes);
|
||||
expect(section(m).querySelectorAll('b')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('titre pressenti : visible dans la grille et sur la fiche', async () => {
|
||||
const m = await evenementOuvert({ personnes: [{ nom: 'Pervenche', prenom: 'Théo', titre: 'hôtesse' }] });
|
||||
const id = idDe(m, 'Pervenche');
|
||||
expect(cellule(m, id, 'titrePressenti').textContent.trim()).toBe('hôtesse');
|
||||
cellule(m, id, 'titrePressenti').scrollIntoView({ block: 'center', inline: 'center' });
|
||||
expect(visible(cellule(m, id, 'titrePressenti'))).toBe(true);
|
||||
m.ui.ouvrirFiche(id);
|
||||
await rendu();
|
||||
const valeur = valeurDeFiche(m, 'participants.colonne.titrePressenti');
|
||||
expect(valeur.textContent).toBe('hôtesse');
|
||||
expect(visible(valeur)).toBe(true);
|
||||
});
|
||||
|
||||
test('supprimer sans réservation : aucune fenêtre, une entrée ; avec réservations, la fenêtre les nomme, et Annuler ne détruit rien', async () => {
|
||||
const m = await evenementOuvert({ tables: 1, personnes: [{ nom: 'Ombrelle' }, { nom: 'Pervenche' }] });
|
||||
const iris = idDe(m, 'Ombrelle');
|
||||
const theo = idDe(m, 'Pervenche');
|
||||
const table = m.seance.etat().charge.tables[0];
|
||||
await m.seance.executer('reserver', { participant: theo, table: table.id, siege: 2, portee: 'tous', tour: null });
|
||||
await rendu();
|
||||
const avant = entrees(m).length;
|
||||
|
||||
await cliquer(commandeDeLigne(m, iris, 'supprimerParticipant'));
|
||||
await attendreSeance(m);
|
||||
aucuneFenetre();
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(participants(m).map((p) => p.id)).toEqual([theo]);
|
||||
|
||||
await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant'));
|
||||
const fenetre = document.querySelector('dialog[open]');
|
||||
expect(fenetre).not.toBeNull();
|
||||
const elements = [...fenetre.querySelectorAll('li')].map((li) => li.textContent);
|
||||
expect(elements).toEqual([
|
||||
t('participants.suppression.reservation', { numero: table.numero, siege: 2, portee: 'tous', tour: null }),
|
||||
]);
|
||||
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
|
||||
await attendreSeance(m);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(participants(m).map((p) => p.id)).toEqual([theo]);
|
||||
|
||||
await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant'));
|
||||
await cliquer(document.querySelector('dialog[open] [data-commande="confirmer"]'));
|
||||
await attendreSeance(m);
|
||||
expect(entrees(m)).toHaveLength(avant + 2);
|
||||
expect(participants(m)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le manque de places (§ 5.9, § 4.4)', () => {
|
||||
test('deux tables de 8 et seize personnes : la dix-septième est acceptée, une entrée, et avertit PLACES_MANQUANTES de 1', async () => {
|
||||
const m = await evenementOuvert({ tables: 2, personnes: NOMS.slice(0, 16).map((nom) => ({ nom })) });
|
||||
const executer = vi.spyOn(m.seance, 'executer');
|
||||
const avant = entrees(m).length;
|
||||
await ajouter(m, { nom: NOMS[16] });
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(participants(m)).toHaveLength(17);
|
||||
expect(executer).toHaveBeenCalledTimes(1);
|
||||
expect(executer.mock.calls[0][0]).toBe('ajouterParticipant');
|
||||
const rendus = await executer.mock.results[0].value;
|
||||
const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge)));
|
||||
expect(attendu).toBe(1);
|
||||
const manque = rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES');
|
||||
expect(manque?.details.placesManquantes).toBe(attendu);
|
||||
const etat = m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES');
|
||||
expect(etat?.details.placesManquantes).toBe(attendu);
|
||||
});
|
||||
|
||||
test('deux tables de 8, dix-sept personnes dont une exclue : la réintégrer est accepté, une entrée, et avertit PLACES_MANQUANTES de 1', async () => {
|
||||
const personnes = NOMS.slice(0, 17).map((nom, rang) => ({ nom, exclu: rang === 5 }));
|
||||
const m = await evenementOuvert({ tables: 2, personnes });
|
||||
const id = idDe(m, NOMS[5]);
|
||||
const executer = vi.spyOn(m.seance, 'executer');
|
||||
const avant = entrees(m).length;
|
||||
await cliquer(commandeDeLigne(m, id, 'reintegrerParticipant'));
|
||||
await attendreSeance(m);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(participants(m).find((p) => p.id === id).exclu).toBe(false);
|
||||
expect(executer).toHaveBeenCalledTimes(1);
|
||||
expect(executer.mock.calls[0]).toEqual(['reintegrerParticipant', { id }]);
|
||||
const rendus = await executer.mock.results[0].value;
|
||||
const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge)));
|
||||
expect(attendu).toBe(1);
|
||||
expect(rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(attendu);
|
||||
expect(m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(
|
||||
attendu,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la fiche (§ 4.2, § 7.4)', () => {
|
||||
test('statuts : A, B, C, D → ancré, ancré, partiellement fixé, mobile, égaux au statut du moteur ; aucun contrôle de saisie de statut', async () => {
|
||||
const m = await evenementOuvert({
|
||||
sieges: 4,
|
||||
tours: 3,
|
||||
tables: 2,
|
||||
personnes: [
|
||||
{ nom: 'Ombrelle', prenom: 'Iris' },
|
||||
{ nom: 'Pervenche', prenom: 'Théo' },
|
||||
{ nom: 'Lacasse', prenom: 'Ondine' },
|
||||
{ nom: 'Grisaille' },
|
||||
],
|
||||
});
|
||||
const [t1, t2] = m.seance.etat().charge.tables.map(({ id }) => id);
|
||||
const [a, b, c, d] = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille'].map((nom) => idDe(m, nom));
|
||||
const reserver = (participant, table, portee, tour = null) =>
|
||||
m.seance.executer('reserver', { participant, table, siege: null, portee, tour });
|
||||
await reserver(a, t1, 'tous');
|
||||
for (const tour of [1, 2, 3]) await reserver(b, t1, 'tour', tour);
|
||||
await reserver(c, t1, 'tour', 1);
|
||||
await reserver(c, t2, 'tour', 2);
|
||||
await rendu();
|
||||
|
||||
const instance = normaliser(configurationDepuisCharge(m.seance.etat().charge));
|
||||
const NOMS_STATUTS = new Map([
|
||||
[STATUT.ANCRE, 'ancre'],
|
||||
[STATUT.PARTIELLEMENT_FIXE, 'partiel'],
|
||||
[STATUT.MOBILE, 'mobile'],
|
||||
]);
|
||||
const attendus = [
|
||||
[a, 'ancre'],
|
||||
[b, 'ancre'],
|
||||
[c, 'partiel'],
|
||||
[d, 'mobile'],
|
||||
];
|
||||
for (const [id, statut] of attendus) {
|
||||
expect(NOMS_STATUTS.get(instance.statut[instance.indexDe.get(id)])).toBe(statut);
|
||||
m.ui.ouvrirFiche(id);
|
||||
await rendu();
|
||||
expect(fiche(m).dataset.fiche).toBe(String(id));
|
||||
expect(valeurDeFiche(m, 'participants.fiche.statut').textContent).toBe(t(cleDeStatut(statut)));
|
||||
expect(fiche(m).querySelectorAll('input, select, textarea')).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('ouverte depuis sa ligne, puis par ui.ouvrirFiche : visible ; sur un retenu, son itinéraire égale, tour par tour, la forme nommée', async () => {
|
||||
const m = await evenementOuvert({ sieges: 4, tours: 3, tables: 2, personnes: NOMS.slice(0, 7).map((nom) => ({ nom })) });
|
||||
await m.seance.executer('modifierReglage', { cle: 'attribuerSieges', valeur: true });
|
||||
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
|
||||
const [proposition] = m.seance.etat().charge.propositions;
|
||||
await m.seance.executer('retenirProposition', { id: proposition.id });
|
||||
// Une personne quitte sa table au tour 2 : la réserve figure à l'itinéraire.
|
||||
await m.seance.executer('retirerDuTour', { tour: 2, participant: idDe(m, NOMS[2]) });
|
||||
await rendu();
|
||||
const charge = m.seance.etat().charge;
|
||||
const forme = formeNommee(charge.retenu, charge);
|
||||
expect(forme.some((ligneForme) => ligneForme.reserve)).toBe(true);
|
||||
|
||||
const id = idDe(m, NOMS[2]);
|
||||
await cliquer(commandeDeLigne(m, id, 'fiche'));
|
||||
expect(m.ui.etat().fiche).toBe(id);
|
||||
expect(fiche(m).dataset.fiche).toBe(String(id));
|
||||
expect(visible(fiche(m))).toBe(true);
|
||||
await cliquer(fiche(m).querySelector('[data-commande="fermerFiche"]'));
|
||||
expect(fiche(m)).toBeNull();
|
||||
expect(m.ui.etat().section).toBe('participants');
|
||||
|
||||
// Chaque personne : l'itinéraire de la fiche, ligne par ligne.
|
||||
expect(charge.participants).toHaveLength(7);
|
||||
for (const { id: personne } of charge.participants) {
|
||||
m.ui.ouvrirFiche(personne);
|
||||
await rendu();
|
||||
expect(visible(fiche(m))).toBe(true);
|
||||
const tableau = tableauDeFiche(m, 'participants.fiche.itineraire');
|
||||
expect(tableau).toBeDefined();
|
||||
const lus = [...tableau.tBodies[0].rows].map((rangee) => [...rangee.cells].map((c) => c.textContent.trim()));
|
||||
const attendus = forme
|
||||
.filter((l) => l.participant === personne)
|
||||
.map((l) => (l.reserve ? [String(l.tour), t('participants.fiche.enReserve')] : [String(l.tour), String(l.numero), String(l.siege)]));
|
||||
expect(attendus).toHaveLength(3);
|
||||
expect(lus).toEqual(attendus);
|
||||
}
|
||||
});
|
||||
|
||||
test('un nom de deux cents caractères se lit entier sur la fiche, replié, sans marque ni débordement', async () => {
|
||||
const long = 'Ombrelle'.repeat(25);
|
||||
expect(long).toHaveLength(200);
|
||||
const m = await evenementOuvert();
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: long } });
|
||||
const id = idDe(m, long);
|
||||
m.ui.ouvrirFiche(id);
|
||||
await rendu();
|
||||
const porteur = fiche(m);
|
||||
const valeur = valeurDeFiche(m, 'participants.colonne.nom');
|
||||
expect(valeur.textContent).toBe(long);
|
||||
expect(porteur.textContent).not.toContain('\u{2026}');
|
||||
expect(porteur.scrollWidth).toBeLessThanOrEqual(porteur.clientWidth);
|
||||
expect(valeur.scrollWidth).toBeLessThanOrEqual(valeur.clientWidth);
|
||||
const interligne = Number.parseFloat(getComputedStyle(valeur).lineHeight);
|
||||
expect(interligne).toBeGreaterThan(0);
|
||||
expect(valeur.getBoundingClientRect().height).toBeGreaterThanOrEqual(2 * interligne);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export et collage (§ 10.2)', () => {
|
||||
test('« Exporter la liste » remet les octets d’exporterParticipants ; réimportés dans un événement neuf, ils rendent les mêmes participants, exclu compris', async () => {
|
||||
const m = await evenementOuvert();
|
||||
const octets = await octetsLivres(CAS_LIMITES);
|
||||
await m.seance.executer('importerParticipants', { apercu: apercevoir(decoder(octets).texte), mode: 'ajouter' });
|
||||
await rendu();
|
||||
const releves = [];
|
||||
m.fs.enregistrerSous = async (nomPropose, contenu) => {
|
||||
releves.push({ nomPropose, octets: contenu });
|
||||
return nomPropose;
|
||||
};
|
||||
const avant = participants(m);
|
||||
expect(avant.some((p) => p.exclu)).toBe(true);
|
||||
const trace = m.trace.length;
|
||||
await cliquer(section(m).querySelector('[data-commande="exporter"]'));
|
||||
await attendreSeance(m);
|
||||
expect(releves).toHaveLength(1);
|
||||
expect(releves[0].nomPropose).toBe(`${base(m)}-participants.csv`);
|
||||
expect([...releves[0].octets]).toEqual([...exporterParticipants(avant)]);
|
||||
expect(m.trace.length).toBe(trace);
|
||||
|
||||
await m.seance.fermer();
|
||||
await m.seance.creer({ nom: 'Soirée des Grisailles', date: null, siegesParDefaut: 8, tours: 2 });
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('importerParticipants', { apercu: apercevoir(decoder(releves[0].octets).texte), mode: 'ajouter' });
|
||||
const sansId = (liste) => liste.map(({ id, ...champs }) => champs);
|
||||
expect(avant.length).toBeGreaterThan(0);
|
||||
expect(sansId(participants(m))).toEqual(sansId(avant));
|
||||
});
|
||||
|
||||
test('un collage sur la grille ouvre l’aperçu d’import : importEnAttente porte le texte intact, origine collage, et la section import ; zéro écriture', async () => {
|
||||
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle' }] });
|
||||
const etats = [];
|
||||
const desabonner = m.ui.subscribe((etat) => etats.push(etat));
|
||||
const trace = m.trace.length;
|
||||
const texte = 'nom;prénom\r\nPervenche;Théo\n"Lacasse; Ondine";\u{00A0}x\t';
|
||||
grille(m).querySelector('[tabindex="0"]').focus();
|
||||
const presse = new DataTransfer();
|
||||
presse.setData('text/plain', texte);
|
||||
grille(m).querySelector('[tabindex="0"]').dispatchEvent(
|
||||
new ClipboardEvent('paste', { clipboardData: presse, bubbles: true, cancelable: true }),
|
||||
);
|
||||
await rendu();
|
||||
desabonner();
|
||||
expect(etats.some((etat) => etat.importEnAttente?.texte === texte && etat.importEnAttente.origine === 'collage' && etat.section === 'import')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(m.ui.etat().section).toBe('import');
|
||||
await attendreSeance(m);
|
||||
expect(m.trace.length).toBe(trace);
|
||||
});
|
||||
});
|
||||
161
src/interface/participants/SectionParticipants.svelte
Normal file
161
src/interface/participants/SectionParticipants.svelte
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section des participants (§ 2.1 étape 3, § 4, § 10.2) : le
|
||||
// formulaire d'ajout, le filtre, le compte, l'export, la grille et la
|
||||
// fiche ouverte. Les lignes de la grille sont celles de lignesGrille
|
||||
// (grille.js) sur la liste de la séance, le tri et le filtre de la
|
||||
// section : regarder, trier et filtrer n'écrivent rien, en écriture comme
|
||||
// en lecture. La fiche ouverte est celle de l'état de navigation, ui.fiche.
|
||||
//
|
||||
// Supprimer une personne qui porte des réservations ouvre la fenêtre à
|
||||
// confirmer, qui les nomme ; sans réservation, ou quand la séance refuse
|
||||
// déjà tout geste modifiant, la commande part et la séance répond. L'export
|
||||
// remet les octets d'exporterParticipants à facade.enregistrerSous, sous le
|
||||
// nom ‹base›-participants.csv (nomExport).
|
||||
import { getContext } from 'svelte';
|
||||
import { cleNormalisee, exporterParticipants } from '../../application/csv.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Fiche from './Fiche.svelte';
|
||||
import Formulaire from './Formulaire.svelte';
|
||||
import Grille from './Grille.svelte';
|
||||
import { lignesGrille, nomExport, reservationsDe } from './grille.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const facade = getContext('facade');
|
||||
const uid = $props.id();
|
||||
|
||||
let tri = $state.raw(null);
|
||||
let filtre = $state('');
|
||||
// La suppression qui attend sa confirmation : {participant, reservations}.
|
||||
let aSupprimer = $state.raw(null);
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const participants = $derived(charge?.participants ?? []);
|
||||
const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee));
|
||||
const personneFiche = $derived($ui.fiche === null ? null : (participants.find(({ id }) => id === $ui.fiche) ?? null));
|
||||
|
||||
async function exporter() {
|
||||
const { evenement, charge: courante } = seance.etat();
|
||||
await facade.enregistrerSous(nomExport(evenement.base), exporterParticipants(courante.participants));
|
||||
}
|
||||
|
||||
function supprimer(participant) {
|
||||
const reservations = reservationsDe(seance.etat().charge, participant.id);
|
||||
if (seance.etat().refus === null && reservations.length > 0) {
|
||||
aSupprimer = { participant, reservations };
|
||||
return null;
|
||||
}
|
||||
return seance.executer('supprimerParticipant', { id: participant.id });
|
||||
}
|
||||
|
||||
async function confirmerSuppression() {
|
||||
await seance.executer('supprimerParticipant', { id: aSupprimer.participant.id });
|
||||
aSupprimer = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section data-section="participants">
|
||||
<h2>{t('coquille.section.participants')}</h2>
|
||||
|
||||
<Formulaire {participants} />
|
||||
|
||||
<div class="outils">
|
||||
<div class="filtre">
|
||||
<label for={`${uid}-filtre`}>{t('participants.grille.filtre')}</label>
|
||||
<input id={`${uid}-filtre`} type="search" data-champ="filtre" bind:value={filtre} />
|
||||
</div>
|
||||
<output>{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}</output>
|
||||
<Commande cle="participants.commande.exporter" nom="exporter" executer={exporter} />
|
||||
</div>
|
||||
|
||||
<div class="corps">
|
||||
<div class="liste">
|
||||
<Grille {lignes} bind:tri {supprimer} />
|
||||
{#if participants.length === 0}
|
||||
<p>{t('participants.grille.vide')}</p>
|
||||
{:else if lignes.length === 0}
|
||||
<p>{t('participants.grille.aucunResultat')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if personneFiche !== null}
|
||||
<Fiche participant={personneFiche} {charge} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if aSupprimer !== null}
|
||||
<Confirmation
|
||||
titre="participants.suppression.titre"
|
||||
details={{ personne: nomAffiche(aSupprimer.participant) }}
|
||||
elements={aSupprimer.reservations.map((reservation) => t('participants.suppression.reservation', reservation))}
|
||||
surConfirmer={confirmerSuppression}
|
||||
surAnnuler={() => (aSupprimer = null)}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
.outils {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
.filtre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
output {
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.corps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
.liste {
|
||||
flex: 1 1 32rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--espace-2) 0;
|
||||
}
|
||||
</style>
|
||||
234
src/interface/participants/grille.js
Normal file
234
src/interface/participants/grille.js
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2), que
|
||||
// câblent SectionParticipants, Grille, Formulaire et Fiche : les colonnes de
|
||||
// la grille, ses lignes triées et filtrées, la cellule qu'atteint une
|
||||
// touche, l'orthographe d'appartenance à reprendre, l'itinéraire d'une
|
||||
// personne dans le retenu et ses réservations. Aucune fonction ne modifie ce
|
||||
// qu'elle reçoit ; la clé normalisée vient de l'appelant, qui la tient de
|
||||
// src/application/csv.js.
|
||||
|
||||
/** Les colonnes de la grille, propriétés d'un participant, dans l'ordre des
|
||||
* colonnes de l'export : « l'ordre de la grille » que relit l'import. */
|
||||
export const COLONNES = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'exclu', 'notes']);
|
||||
|
||||
// Les colonnes de texte : toutes, exclu excepté.
|
||||
const TEXTES = COLONNES.filter((colonne) => colonne !== 'exclu');
|
||||
|
||||
// La clé du libellé de chaque colonne, et celle de chaque statut calculé.
|
||||
const CLES_COLONNES = new Map([
|
||||
['nom', 'participants.colonne.nom'],
|
||||
['prenom', 'participants.colonne.prenom'],
|
||||
['appartenance', 'participants.colonne.appartenance'],
|
||||
['courriel', 'participants.colonne.courriel'],
|
||||
['titrePressenti', 'participants.colonne.titrePressenti'],
|
||||
['exclu', 'participants.colonne.exclu'],
|
||||
['notes', 'participants.colonne.notes'],
|
||||
]);
|
||||
const CLES_STATUTS = new Map([
|
||||
['ancre', 'participants.statut.ancre'],
|
||||
['partiel', 'participants.statut.partiel'],
|
||||
['mobile', 'participants.statut.mobile'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* La clé du libellé d'une colonne de COLONNES.
|
||||
*
|
||||
* @param {string} colonne
|
||||
* @returns {string}
|
||||
* @throws {RangeError} hors de COLONNES
|
||||
*/
|
||||
export function cleDeColonne(colonne) {
|
||||
const cle = CLES_COLONNES.get(colonne);
|
||||
if (cle === undefined) throw new RangeError(`colonne inconnue : ${JSON.stringify(colonne)}`);
|
||||
return cle;
|
||||
}
|
||||
|
||||
/**
|
||||
* La clé du libellé d'un statut calculé (statuts, src/application/places.js) :
|
||||
* 'ancre', 'partiel' ou 'mobile'.
|
||||
*
|
||||
* @param {string} statut
|
||||
* @returns {string}
|
||||
* @throws {RangeError} un autre statut
|
||||
*/
|
||||
export function cleDeStatut(statut) {
|
||||
const cle = CLES_STATUTS.get(statut);
|
||||
if (cle === undefined) throw new RangeError(`statut inconnu : ${JSON.stringify(statut)}`);
|
||||
return cle;
|
||||
}
|
||||
|
||||
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
const SENS = new Map([
|
||||
['croissant', 1],
|
||||
['decroissant', -1],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Les lignes affichées : une ligne reste quand la clé normalisée d'un de ses
|
||||
* champs texte non null contient celle du filtre — un champ null n'est
|
||||
* jamais passé à cleNormalisee, qui lève sur lui —, puis tri par colonne,
|
||||
* croissant ou décroissant, sur la clé normalisée (exclu : faux avant vrai),
|
||||
* un champ null après toute valeur dans les deux sens, départagé par
|
||||
* identifiant croissant. Sans tri, l'ordre est celui des identifiants
|
||||
* croissants ; un filtre dont la clé est vide garde tout.
|
||||
*
|
||||
* @param {Array<Object>} participants
|
||||
* @param {{tri: null|{colonne: string, sens: 'croissant'|'decroissant'}, filtre: string}} vue
|
||||
* @param {(texte: string) => string} cleNormalisee
|
||||
* @returns {Array<Object>} les participants reçus, dans l'ordre de la grille
|
||||
* @throws {RangeError} une colonne hors de COLONNES, un autre sens
|
||||
*/
|
||||
export function lignesGrille(participants, { tri, filtre }, cleNormalisee) {
|
||||
const signe = tri === null ? 1 : SENS.get(tri.sens);
|
||||
if (tri !== null && (!COLONNES.includes(tri.colonne) || signe === undefined)) {
|
||||
throw new RangeError(`tri inconnu : ${JSON.stringify(tri)}`);
|
||||
}
|
||||
const cherche = cleNormalisee(filtre ?? '');
|
||||
const retenues =
|
||||
cherche === ''
|
||||
? [...participants]
|
||||
: participants.filter((p) => TEXTES.some((colonne) => p[colonne] !== null && cleNormalisee(p[colonne]).includes(cherche)));
|
||||
|
||||
// La valeur de tri de chaque ligne, calculée une fois : null reste null.
|
||||
const colonne = tri?.colonne ?? null;
|
||||
const valeurDe = (p) => {
|
||||
if (colonne === null) return null;
|
||||
if (colonne === 'exclu') return p.exclu ? 1 : 0;
|
||||
return p[colonne] === null ? null : cleNormalisee(p[colonne]);
|
||||
};
|
||||
const valeurs = new Map(retenues.map((p) => [p, valeurDe(p)]));
|
||||
const comparer = (a, b) => {
|
||||
const va = valeurs.get(a);
|
||||
const vb = valeurs.get(b);
|
||||
if (va !== vb) {
|
||||
if (va === null) return 1;
|
||||
if (vb === null) return -1;
|
||||
const ecart = typeof va === 'number' ? va - vb : comparerTextes(va, vb);
|
||||
if (ecart !== 0) return ecart * signe;
|
||||
}
|
||||
return a.id - b.id;
|
||||
};
|
||||
return retenues.sort(comparer);
|
||||
}
|
||||
|
||||
/**
|
||||
* La cellule qu'atteint une touche depuis la cellule position, dans une
|
||||
* grille de lignes × colonnes. Les flèches bougent d'une cellule et restent
|
||||
* sur place au bord ; Tab avance dans la ligne puis passe au début de la
|
||||
* suivante, Maj+Tab recule de même. Tab après la dernière cellule, Maj+Tab
|
||||
* avant la première, rendent null : le focus quitte la grille, et le
|
||||
* navigateur le mène à l'élément suivant. Une autre touche ne bouge rien.
|
||||
* Une grille sans ligne n'a pas de cellule : null.
|
||||
*
|
||||
* @param {{ligne: number, colonne: number}} position
|
||||
* @param {string} touche 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
|
||||
* 'Tab' ou 'Maj+Tab'
|
||||
* @param {{lignes: number, colonnes: number}} taille
|
||||
* @returns {{ligne: number, colonne: number}|null}
|
||||
*/
|
||||
export function celluleSuivante({ ligne, colonne }, touche, { lignes, colonnes }) {
|
||||
if (lignes === 0 || colonnes === 0) return null;
|
||||
const borner = (valeur, max) => (valeur < 0 ? 0 : valeur > max ? max : valeur);
|
||||
const rang = ligne * colonnes + colonne;
|
||||
switch (touche) {
|
||||
case 'ArrowUp':
|
||||
return { ligne: borner(ligne - 1, lignes - 1), colonne };
|
||||
case 'ArrowDown':
|
||||
return { ligne: borner(ligne + 1, lignes - 1), colonne };
|
||||
case 'ArrowLeft':
|
||||
return { ligne, colonne: borner(colonne - 1, colonnes - 1) };
|
||||
case 'ArrowRight':
|
||||
return { ligne, colonne: borner(colonne + 1, colonnes - 1) };
|
||||
case 'Tab':
|
||||
return rang + 1 >= lignes * colonnes ? null : { ligne: Math.floor((rang + 1) / colonnes), colonne: (rang + 1) % colonnes };
|
||||
case 'Maj+Tab':
|
||||
return rang === 0 ? null : { ligne: Math.floor((rang - 1) / colonnes), colonne: (rang - 1) % colonnes };
|
||||
default:
|
||||
return { ligne, colonne };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L'orthographe déjà portée par un autre participant pour une appartenance
|
||||
* saisie, à proposer sans fusionner : la première, dans l'ordre reçu, dont
|
||||
* la clé normalisée égale celle de la saisie. null quand la saisie est
|
||||
* nouvelle, vide, ou identique à une appartenance portée — lue comme la
|
||||
* saisie l'enregistre, en NFC, sans ses blancs de bord. Une appartenance
|
||||
* null ne se compare pas.
|
||||
*
|
||||
* @param {string|null} saisie
|
||||
* @param {Array<{appartenance: string|null}>} participants
|
||||
* @param {(texte: string) => string} cleNormalisee
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function orthographeExistante(saisie, participants, cleNormalisee) {
|
||||
if (saisie === null) return null;
|
||||
const texte = saisie.normalize('NFC').trim();
|
||||
if (texte === '') return null;
|
||||
const portees = participants.map(({ appartenance }) => appartenance).filter((a) => a !== null);
|
||||
if (portees.includes(texte)) return null;
|
||||
const cle = cleNormalisee(texte);
|
||||
return portees.find((appartenance) => cleNormalisee(appartenance) === cle) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'itinéraire d'une personne dans le retenu, pour la fiche : à chaque
|
||||
* tour, de 1 à tours, la table (numéro affiché) et le siège — null quand le
|
||||
* retenu n'attribue pas les sièges —, ou la réserve. Lu dans etatDuTour(r)
|
||||
* = etatPlaces(charge, {tour: r}), jamais recalculé ; null quand la source
|
||||
* n'y est pas 'retenu' à l'un des tours, ou que la personne n'y est ni
|
||||
* assise ni en réserve — une exclue, absente de tout tour.
|
||||
*
|
||||
* @param {number} id
|
||||
* @param {number} tours
|
||||
* @param {(tour: number) => Object} etatDuTour l'EtatPlaces du tour
|
||||
* @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}>|null}
|
||||
*/
|
||||
export function itineraire(id, tours, etatDuTour) {
|
||||
const etapes = [];
|
||||
for (let tour = 1; tour <= tours; tour += 1) {
|
||||
const etat = etatDuTour(tour);
|
||||
if (etat.source !== 'retenu') return null;
|
||||
let etape = null;
|
||||
for (const table of etat.tables) {
|
||||
const chaise = table.chaises.find(({ occupant }) => occupant === id);
|
||||
if (chaise !== undefined) {
|
||||
etape = { tour, numero: table.numero, siege: etat.siegesAttribues ? chaise.siege : null };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (etape === null && etat.reserve.includes(id)) etape = { tour, reserve: true };
|
||||
if (etape === null) return null;
|
||||
etapes.push(etape);
|
||||
}
|
||||
return etapes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les réservations d'une personne, pour la fiche et la fenêtre de
|
||||
* suppression : dans l'ordre de la charge, chacune avec le numéro affiché
|
||||
* de sa table.
|
||||
*
|
||||
* @param {{tables: Array<{id: number, numero: number}>, reservations: Array<Object>}} charge
|
||||
* @param {number} id
|
||||
* @returns {Array<{numero: number, siege: number|null, portee: 'tous'|'tour', tour: number|null}>}
|
||||
*/
|
||||
export function reservationsDe(charge, id) {
|
||||
const numeroDe = new Map(charge.tables.map((table) => [table.id, table.numero]));
|
||||
return charge.reservations
|
||||
.filter(({ participant }) => participant === id)
|
||||
.map(({ table, siege, portee, tour }) => ({ numero: numeroDe.get(table), siege, portee, tour }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le nom proposé à l'enregistrement de l'export de la liste (§ 10.2) :
|
||||
* ‹base›-participants.csv, base étant celle de l'événement ouvert.
|
||||
*
|
||||
* @param {string} base
|
||||
* @returns {string}
|
||||
*/
|
||||
export const nomExport = (base) => `${base}-participants.csv`;
|
||||
305
src/interface/participants/grille.test.js
Normal file
305
src/interface/participants/grille.test.js
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2) : les
|
||||
// lignes de la grille, triées et filtrées, la navigation au clavier,
|
||||
// l'orthographe d'appartenance à reprendre, l'itinéraire d'une personne dans
|
||||
// le retenu, ses réservations, et l'ordre des colonnes, celui de l'export.
|
||||
// Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { cleNormalisee, decoder, exporterParticipants } from '../../application/csv.js';
|
||||
import {
|
||||
COLONNES,
|
||||
celluleSuivante,
|
||||
cleDeColonne,
|
||||
cleDeStatut,
|
||||
itineraire,
|
||||
lignesGrille,
|
||||
nomExport,
|
||||
orthographeExistante,
|
||||
reservationsDe,
|
||||
} from './grille.js';
|
||||
|
||||
// Un participant ; les champs absents valent null, exclu faux.
|
||||
const personne = (id, nom, champs = {}) => ({
|
||||
id,
|
||||
nom,
|
||||
prenom: null,
|
||||
appartenance: null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...champs,
|
||||
});
|
||||
|
||||
const ids = (lignes) => lignes.map(({ id }) => id);
|
||||
|
||||
describe('lignesGrille : tri et filtre (§ 10.2)', () => {
|
||||
const participants = [
|
||||
personne(4, 'Pervenche', { prenom: 'Théo', appartenance: 'CLUB DES MERLES' }),
|
||||
personne(1, 'Ombrelle', { prenom: 'Iris', appartenance: 'Club des Merles' }),
|
||||
personne(3, 'Lacasse', { prenom: 'Ondine' }),
|
||||
personne(2, 'Ombrelle', { prenom: 'Anselme' }),
|
||||
personne(5, 'Grisaille', { exclu: true }),
|
||||
];
|
||||
|
||||
test('sans tri ni filtre, par identifiant croissant', () => {
|
||||
assert.deepEqual(ids(lignesGrille(participants, { tri: null, filtre: '' }, cleNormalisee)), [1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('par nom, croissant puis décroissant ; deux noms égaux se départagent par identifiant croissant', () => {
|
||||
const croissant = lignesGrille(participants, { tri: { colonne: 'nom', sens: 'croissant' }, filtre: '' }, cleNormalisee);
|
||||
assert.deepEqual(ids(croissant), [5, 3, 1, 2, 4]);
|
||||
const decroissant = lignesGrille(participants, { tri: { colonne: 'nom', sens: 'decroissant' }, filtre: '' }, cleNormalisee);
|
||||
assert.deepEqual(ids(decroissant), [4, 1, 2, 3, 5]);
|
||||
});
|
||||
|
||||
test('la clé normalisée trie : casse et accents ignorés', () => {
|
||||
const liste = [personne(1, 'Émeraude'), personne(2, 'elan'), personne(3, 'Ezra')];
|
||||
const lignes = lignesGrille(liste, { tri: { colonne: 'nom', sens: 'croissant' }, filtre: '' }, cleNormalisee);
|
||||
assert.deepEqual(ids(lignes), [2, 1, 3]);
|
||||
});
|
||||
|
||||
test('exclu : faux avant vrai en croissant, vrai avant faux en décroissant', () => {
|
||||
const croissant = lignesGrille(participants, { tri: { colonne: 'exclu', sens: 'croissant' }, filtre: '' }, cleNormalisee);
|
||||
assert.deepEqual(ids(croissant), [1, 2, 3, 4, 5]);
|
||||
const decroissant = lignesGrille(participants, { tri: { colonne: 'exclu', sens: 'decroissant' }, filtre: '' }, cleNormalisee);
|
||||
assert.deepEqual(ids(decroissant), [5, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('filtre « merles » : « Club des Merles » et « CLUB DES MERLES » restent, la clé normalisée du filtre comprise', () => {
|
||||
for (const filtre of ['merles', 'MERLES', ' Mérles ']) {
|
||||
assert.deepEqual(ids(lignesGrille(participants, { tri: null, filtre }, cleNormalisee)), [1, 4], filtre);
|
||||
}
|
||||
});
|
||||
|
||||
test('le filtre lit chaque champ texte : prénom, courriel, titre pressenti, notes', () => {
|
||||
const liste = [
|
||||
personne(1, 'Ombrelle', { courriel: 'iris@exemple.test' }),
|
||||
personne(2, 'Pervenche', { titrePressenti: 'hôtesse' }),
|
||||
personne(3, 'Lacasse', { notes: 'arrive tard' }),
|
||||
personne(4, 'Grisaille', { prenom: 'Hôtesse' }),
|
||||
];
|
||||
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'exemple' }, cleNormalisee)), [1]);
|
||||
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'hotesse' }, cleNormalisee)), [2, 4]);
|
||||
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'tard' }, cleNormalisee)), [3]);
|
||||
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'absent' }, cleNormalisee)), []);
|
||||
});
|
||||
|
||||
test('des champs null : le filtre ne lève pas, et le tri les range après toute valeur, croissant comme décroissant', () => {
|
||||
const vides = ['prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
|
||||
// 1 et 3 portent chaque champ, 2 et 4 aucun : les null viennent après,
|
||||
// par identifiant croissant, dans les deux sens.
|
||||
const liste = [
|
||||
personne(4, 'Grisaille'),
|
||||
personne(3, 'Lacasse', Object.fromEntries(vides.map((champ) => [champ, 'zinnia']))),
|
||||
personne(2, 'Pervenche'),
|
||||
personne(1, 'Ombrelle', Object.fromEntries(vides.map((champ) => [champ, 'acacia']))),
|
||||
];
|
||||
assert.doesNotThrow(() => lignesGrille(liste, { tri: null, filtre: 'acacia' }, cleNormalisee));
|
||||
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'acacia' }, cleNormalisee)), [1]);
|
||||
assert.ok(vides.length > 0);
|
||||
for (const colonne of vides) {
|
||||
const croissant = lignesGrille(liste, { tri: { colonne, sens: 'croissant' }, filtre: '' }, cleNormalisee);
|
||||
assert.deepEqual(ids(croissant), [1, 3, 2, 4], `${colonne} croissant`);
|
||||
const decroissant = lignesGrille(liste, { tri: { colonne, sens: 'decroissant' }, filtre: '' }, cleNormalisee);
|
||||
assert.deepEqual(ids(decroissant), [3, 1, 2, 4], `${colonne} décroissant`);
|
||||
}
|
||||
});
|
||||
|
||||
test('cleNormalisee ne reçoit jamais null', () => {
|
||||
const recus = [];
|
||||
const espion = (texte) => {
|
||||
recus.push(texte);
|
||||
return cleNormalisee(texte);
|
||||
};
|
||||
const liste = [personne(1, 'Ombrelle'), personne(2, 'Pervenche', { notes: 'x' })];
|
||||
lignesGrille(liste, { tri: { colonne: 'notes', sens: 'croissant' }, filtre: 'o' }, espion);
|
||||
assert.ok(recus.length > 0);
|
||||
assert.ok(recus.every((texte) => typeof texte === 'string'));
|
||||
});
|
||||
|
||||
test('ne modifie pas la liste reçue', () => {
|
||||
const copie = structuredClone(participants);
|
||||
lignesGrille(participants, { tri: { colonne: 'nom', sens: 'decroissant' }, filtre: '' }, cleNormalisee);
|
||||
assert.deepEqual(participants, copie);
|
||||
});
|
||||
|
||||
test('une colonne de tri inconnue, un sens inconnu : RangeError', () => {
|
||||
assert.throws(() => lignesGrille(participants, { tri: { colonne: 'id', sens: 'croissant' }, filtre: '' }, cleNormalisee), RangeError);
|
||||
assert.throws(() => lignesGrille(participants, { tri: { colonne: 'nom', sens: 'haut' }, filtre: '' }, cleNormalisee), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('celluleSuivante : flèches, Tab, Maj+Tab (§ 10.2)', () => {
|
||||
const taille = { lignes: 3, colonnes: 4 };
|
||||
|
||||
test('les flèches bougent d’une cellule et restent sur place au bord', () => {
|
||||
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowDown', taille), { ligne: 2, colonne: 1 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowUp', taille), { ligne: 0, colonne: 1 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowRight', taille), { ligne: 1, colonne: 2 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowLeft', taille), { ligne: 1, colonne: 0 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowUp', taille), { ligne: 0, colonne: 0 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowLeft', taille), { ligne: 0, colonne: 0 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 2, colonne: 3 }, 'ArrowDown', taille), { ligne: 2, colonne: 3 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 2, colonne: 3 }, 'ArrowRight', taille), { ligne: 2, colonne: 3 });
|
||||
});
|
||||
|
||||
test('Tab avance dans la ligne puis passe au début de la suivante ; Maj+Tab recule de même', () => {
|
||||
assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 2 }, 'Tab', taille), { ligne: 0, colonne: 3 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 3 }, 'Tab', taille), { ligne: 1, colonne: 0 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 0 }, 'Maj+Tab', taille), { ligne: 0, colonne: 3 });
|
||||
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 2 }, 'Maj+Tab', taille), { ligne: 1, colonne: 1 });
|
||||
});
|
||||
|
||||
test('Tab après la dernière cellule, Maj+Tab avant la première : null, le focus quitte la grille', () => {
|
||||
assert.equal(celluleSuivante({ ligne: 2, colonne: 3 }, 'Tab', taille), null);
|
||||
assert.equal(celluleSuivante({ ligne: 0, colonne: 0 }, 'Maj+Tab', taille), null);
|
||||
});
|
||||
|
||||
test('une autre touche ne bouge rien ; une grille vide n’a pas de cellule', () => {
|
||||
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'Enter', taille), { ligne: 1, colonne: 1 });
|
||||
assert.equal(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowDown', { lignes: 0, colonnes: 4 }), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orthographeExistante (§ 10.1, § 10.2)', () => {
|
||||
const participants = [
|
||||
personne(1, 'Ombrelle', { appartenance: 'Club des Merles' }),
|
||||
personne(2, 'Pervenche'),
|
||||
personne(3, 'Lacasse', { appartenance: 'Atelier Grisaille' }),
|
||||
];
|
||||
|
||||
test('« club des merles » → « Club des Merles », l’orthographe déjà portée', () => {
|
||||
assert.equal(orthographeExistante('club des merles', participants, cleNormalisee), 'Club des Merles');
|
||||
assert.equal(orthographeExistante('CLUB DÉS MERLES', participants, cleNormalisee), 'Club des Merles');
|
||||
});
|
||||
|
||||
test('null pour une saisie identique, nouvelle, vide ou null ; une appartenance null ne se compare pas', () => {
|
||||
assert.equal(orthographeExistante('Club des Merles', participants, cleNormalisee), null);
|
||||
assert.equal(orthographeExistante(' Club des Merles ', participants, cleNormalisee), null);
|
||||
assert.equal(orthographeExistante('Cercle des Saules', participants, cleNormalisee), null);
|
||||
assert.equal(orthographeExistante('', participants, cleNormalisee), null);
|
||||
assert.equal(orthographeExistante(' ', participants, cleNormalisee), null);
|
||||
assert.equal(orthographeExistante(null, participants, cleNormalisee), null);
|
||||
});
|
||||
|
||||
test('une saisie déjà portée telle quelle par une personne ne propose rien, même quand une autre l’écrit autrement', () => {
|
||||
const deux = [...participants, personne(4, 'Grisaille', { appartenance: 'club des merles' })];
|
||||
assert.equal(orthographeExistante('club des merles', deux, cleNormalisee), null);
|
||||
assert.equal(orthographeExistante('Club Des Merles', deux, cleNormalisee), 'Club des Merles');
|
||||
});
|
||||
});
|
||||
|
||||
describe("itineraire : la personne dans le retenu, tour par tour (§ 7.4)", () => {
|
||||
// Un état de places écrit à la main, réduit à ce que lit itineraire.
|
||||
const etat = (source, siegesAttribues, tables, reserve) => ({ source, siegesAttribues, tables, reserve });
|
||||
const tableDe = (numero, occupants) => ({
|
||||
id: numero * 10,
|
||||
numero,
|
||||
chaises: occupants.map((occupant, rang) => ({ siege: rang + 1, occupant })),
|
||||
});
|
||||
|
||||
test('à la table 4, siège 2, au tour 1, en réserve au tour 2', () => {
|
||||
const etats = new Map([
|
||||
[1, etat('retenu', true, [tableDe(3, [1, 2]), tableDe(4, [5, 7])], [])],
|
||||
[2, etat('retenu', true, [tableDe(3, [1, 2]), tableDe(4, [5, null])], [7])],
|
||||
]);
|
||||
assert.deepEqual(itineraire(7, 2, (r) => etats.get(r)), [
|
||||
{ tour: 1, numero: 4, siege: 2 },
|
||||
{ tour: 2, reserve: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('sièges non attribués : siège null', () => {
|
||||
const etats = new Map([[1, etat('retenu', false, [tableDe(4, [5, 7])], [])]]);
|
||||
assert.deepEqual(itineraire(7, 1, (r) => etats.get(r)), [{ tour: 1, numero: 4, siege: null }]);
|
||||
});
|
||||
|
||||
test('source reservations ou proposition, à un tour : null', () => {
|
||||
const retenu = etat('retenu', true, [tableDe(4, [7])], []);
|
||||
for (const source of ['reservations', 'proposition']) {
|
||||
const etats = new Map([
|
||||
[1, retenu],
|
||||
[2, etat(source, true, [tableDe(4, [7])], [])],
|
||||
]);
|
||||
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null, source);
|
||||
}
|
||||
});
|
||||
|
||||
test("une personne qu'aucune chaise ne reçoit et qui n'attend pas — exclue — : null", () => {
|
||||
const etats = new Map([[1, etat('retenu', true, [tableDe(4, [5])], [6])]]);
|
||||
assert.equal(itineraire(7, 1, (r) => etats.get(r)), null);
|
||||
});
|
||||
|
||||
test('lit chaque tour de 1 à tours, une fois', () => {
|
||||
const lus = [];
|
||||
itineraire(7, 3, (r) => {
|
||||
lus.push(r);
|
||||
return etat('retenu', true, [tableDe(1, [7])], []);
|
||||
});
|
||||
assert.deepEqual(lus, [1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reservationsDe : les réservations d’une personne, pour la fiche (§ 4)', () => {
|
||||
test('les siennes, dans l’ordre de la charge, la table par son numéro affiché', () => {
|
||||
const charge = {
|
||||
tables: [
|
||||
{ id: 1, numero: 3 },
|
||||
{ id: 2, numero: 1 },
|
||||
],
|
||||
reservations: [
|
||||
{ participant: 7, table: 2, siege: null, portee: 'tous', tour: null },
|
||||
{ participant: 8, table: 1, siege: 1, portee: 'tous', tour: null },
|
||||
{ participant: 7, table: 1, siege: 4, portee: 'tour', tour: 2 },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(reservationsDe(charge, 7), [
|
||||
{ numero: 1, siege: null, portee: 'tous', tour: null },
|
||||
{ numero: 3, siege: 4, portee: 'tour', tour: 2 },
|
||||
]);
|
||||
assert.deepEqual(reservationsDe(charge, 9), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => {
|
||||
test('la cellule de rang i de la ligne exportée égale p[COLONNES[i]], exclu écrit « non »', () => {
|
||||
const p = {
|
||||
id: 1,
|
||||
nom: 'Ombrelle',
|
||||
prenom: 'Iris',
|
||||
appartenance: 'Club des Merles',
|
||||
courriel: 'iris@exemple.test',
|
||||
titrePressenti: 'hôtesse',
|
||||
notes: 'arrive à neuf heures',
|
||||
exclu: false,
|
||||
};
|
||||
const { texte } = decoder(exporterParticipants([p]));
|
||||
const [, ligne] = texte.split('\r\n');
|
||||
const cellules = ligne.split(';');
|
||||
assert.equal(COLONNES.length, 7);
|
||||
assert.equal(cellules.length, COLONNES.length);
|
||||
COLONNES.forEach((colonne, i) => {
|
||||
assert.equal(cellules[i], colonne === 'exclu' ? 'non' : p[colonne], colonne);
|
||||
});
|
||||
});
|
||||
|
||||
test('le nom proposé à l’enregistrement : ‹base›-participants.csv', () => {
|
||||
assert.equal(nomExport('soiree-des-merles'), 'soiree-des-merles-participants.csv');
|
||||
});
|
||||
|
||||
test('chaque colonne et chaque statut ont leur clé de libellé', () => {
|
||||
assert.deepEqual(
|
||||
COLONNES.map(cleDeColonne),
|
||||
COLONNES.map((colonne) => `participants.colonne.${colonne}`),
|
||||
);
|
||||
assert.equal(cleDeStatut('ancre'), 'participants.statut.ancre');
|
||||
assert.equal(cleDeStatut('partiel'), 'participants.statut.partiel');
|
||||
assert.equal(cleDeStatut('mobile'), 'participants.statut.mobile');
|
||||
assert.throws(() => cleDeColonne('id'), RangeError);
|
||||
assert.throws(() => cleDeStatut('exclu'), RangeError);
|
||||
});
|
||||
});
|
||||
60
src/interface/plan/BandeauPlan.svelte
Normal file
60
src/interface/plan/BandeauPlan.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le bandeau du plan (§ 7.5), au-dessus du <svg data-plan> et dans le
|
||||
// cadre de mode : vide tant qu'une table au moins est saine ; quand toutes
|
||||
// sont en conflit, la phrase, puis ce que le diagnostic prouve — chaque
|
||||
// entrée {cle, details} du modèle (modele.js), traduite. L'appartenance
|
||||
// d'un plancher de groupe est une donnée : elle occupe son propre nœud,
|
||||
// avant le texte. Le bandeau d'application garde la version, la
|
||||
// provenance et le mode.
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
let { bandeau } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
{#if bandeau.length > 0}
|
||||
<div class="bandeau-plan" data-bandeau-plan role="status">
|
||||
<ul>
|
||||
{#each bandeau as entree, rang (rang)}
|
||||
<li>
|
||||
{#if entree.details.groupe !== undefined}<span class="donnee">{entree.details.groupe}</span>{/if}<span
|
||||
>{t(entree.cle, entree.details)}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bandeau-plan {
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-left: 4px solid var(--couleur-avis);
|
||||
background: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.donnee {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue