1057 lines
47 KiB
JavaScript
1057 lines
47 KiB
JavaScript
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|||
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|||
|
|
|
|||
|
|
// Le registre des commandes, éprouvé en entier (§ 2.1, § 8.2, § 8.3, § 8.4,
|
|||
|
|
// § 9, § 5.8, § 17 point 3, § 14.12, § 14.15) : chaque commande de COMMANDES
|
|||
|
|
// passe par la séance, sur le système de fichiers d'épreuve et l'horloge
|
|||
|
|
// pilotée, et l'épreuve relève ce qui s'écrit.
|
|||
|
|
//
|
|||
|
|
// La table EXEMPLES donne, par commande, un exemple accepté, un exemple
|
|||
|
|
// refusé — un refus du domaine — et un exemple sans effet ; une commande qui
|
|||
|
|
// n'a pas l'un d'eux y nomme sa raison. Un exemple porte ses arguments, ou la
|
|||
|
|
// fonction qui les tire de la charge où il s'exécute, et les gestes qui le
|
|||
|
|
// préparent (avant), exécutés d'abord, chacun une entrée. REMEDE_NUL_ADMIS
|
|||
|
|
// nomme les refus dont le remède est nul, avec la raison : la valeur se
|
|||
|
|
// corrige dans le champ même, ou aucun geste ne lève le refus.
|
|||
|
|
//
|
|||
|
|
// Deux événements servent de départ, construits une fois par la façade et la
|
|||
|
|
// séance, puis copiés fichier pour fichier dans un système neuf pour chaque
|
|||
|
|
// épreuve : un événement vide, créé, et la charge témoin — la petite
|
|||
|
|
// démonstration chargée, « Modifier », une désignation, deux propositions
|
|||
|
|
// générées sur l'exécuteur direct, la seconde retenue. Ni l'un ni l'autre
|
|||
|
|
// n'est un fichier d'épreuve : la démonstration porte des noms du réservoir
|
|||
|
|
// du générateur, et une copie dériverait d'elle.
|
|||
|
|
//
|
|||
|
|
// La trace des écritures enveloppe ajouterLigne et ecrireAtomique du système
|
|||
|
|
// d'épreuve : une ligne d'entrée du journal, ou le journal réécrit entier,
|
|||
|
|
// compte pour l'entrée ; une ligne de jalon ne compte pas. Défaire rend la
|
|||
|
|
// charge d'avant, ses compteurs d'identifiants exceptés : la séance les porte
|
|||
|
|
// au plus grand de leur valeur restituée et de leur valeur courante (§ 8.9),
|
|||
|
|
// et l'épreuve compare la charge d'avant sous les compteurs rendus. 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 { apercevoir } from '../csv/apercu.js';
|
|||
|
|
import { CATALOGUE } from '../demo/catalogue.js';
|
|||
|
|
import { LIVREES } from '../demo/livrees.js';
|
|||
|
|
import { normaliser } from '../moteur/configuration.js';
|
|||
|
|
import { rechercher } from '../moteur/recherche.js';
|
|||
|
|
import { verifierInvariants } from '../moteur/verification.js';
|
|||
|
|
import { serialiserCharge } from '../stockage/canonique.js';
|
|||
|
|
import { creer as creerDansLeDepot } from '../stockage/depot.js';
|
|||
|
|
import { analyser, configurationDepuisCharge } from '../stockage/document.js';
|
|||
|
|
import { lireJournal, reconstruire } from '../stockage/journal.js';
|
|||
|
|
import { SUFFIXES } from '../stockage/noms.js';
|
|||
|
|
import { planDepuisFichier } from '../stockage/placements.js';
|
|||
|
|
import { VERSION } from '../version.genere.js';
|
|||
|
|
import { COMMANDES } from './commandes.js';
|
|||
|
|
import { creerFacade } from './evenements.js';
|
|||
|
|
import { creerControleurGeneration, creerExecuteurDirect } from './generation.js';
|
|||
|
|
import { creerHorlogePilotee } from './horloge.js';
|
|||
|
|
import { libelle } from './libelles.js';
|
|||
|
|
import { nomAffiche } from './personnes.js';
|
|||
|
|
import { creerSeance } from './seance.js';
|
|||
|
|
|
|||
|
|
const V = VERSION.affichee;
|
|||
|
|
const DEPART = '2031-03-14T18:00:00-04:00';
|
|||
|
|
const NOM_VIDE = 'Soirée des Hérons';
|
|||
|
|
const TITRE = 'Animation des Hérons';
|
|||
|
|
const AUTRE_TITRE = 'Accueil des Hérons';
|
|||
|
|
|
|||
|
|
const DEMONSTRATIONS = LIVREES.map(({ cle, texte }) => ({
|
|||
|
|
cle,
|
|||
|
|
nom: CATALOGUE.find((entree) => entree.cle === cle).nom,
|
|||
|
|
texte,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// --- Lectures de la charge ---------------------------------------------------
|
|||
|
|
|
|||
|
|
const participantDe = (charge, id) => charge.participants.find((personne) => personne.id === id);
|
|||
|
|
const tableDe = (charge, id) => charge.tables.find((table) => table.id === id);
|
|||
|
|
const derniereTable = (charge) => Math.max(...charge.tables.map(({ id }) => id));
|
|||
|
|
const croissant = (a, b) => a - b;
|
|||
|
|
|
|||
|
|
// Les personnes que le retenu assied au tour à la table d'identifiant
|
|||
|
|
// idTable, sans réservation, par identifiant croissant.
|
|||
|
|
function libresA(charge, tour, idTable) {
|
|||
|
|
const reservees = new Set(charge.reservations.map(({ participant }) => participant));
|
|||
|
|
const rang = charge.retenu.tables.indexOf(idTable);
|
|||
|
|
return charge.retenu.placement[tour - 1].sieges[rang].filter((id) => id !== null && !reservees.has(id)).sort(croissant);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Les violations de verifierInvariants du retenu de la charge, la réserve
|
|||
|
|
// admise (§ 12.6) ; [] sur une configuration qui ne se normalise pas.
|
|||
|
|
function violationsDuRetenu(charge, { reserveAutorisee = true } = {}) {
|
|||
|
|
let instance;
|
|||
|
|
try {
|
|||
|
|
instance = normaliser(configurationDepuisCharge(charge));
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Les violations de apres que avant ne porte pas, comparées champ pour champ.
|
|||
|
|
function violationsNouvelles(avant, apres) {
|
|||
|
|
const connues = new Set(violationsDuRetenu(avant).map((violation) => JSON.stringify(violation)));
|
|||
|
|
return violationsDuRetenu(apres).filter((violation) => !connues.has(JSON.stringify(violation)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- La table des exemples ---------------------------------------------------
|
|||
|
|
|
|||
|
|
// Les refus admis à remède null, et pourquoi.
|
|||
|
|
const REMEDE_NUL_ADMIS = new Map([
|
|||
|
|
['MOINS_DE_DEUX', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
|
|||
|
|
['NON_ENTIER', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
|
|||
|
|
['MOINS_D_UN_TOUR', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
|
|||
|
|
['LIBELLE_VIDE', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
|
|||
|
|
['NOM_REQUIS', 'le nom se saisit dans le champ même'],
|
|||
|
|
['PARTICIPANT_INCONNU', "une personne retirée entre-temps : aucun geste ne la rend"],
|
|||
|
|
['PROPOSITION_INCONNUE', "une proposition effacée entre-temps : aucun geste ne la rend"],
|
|||
|
|
['COMPTEUR_SATURE', "un compteur au bout de sa règle : aucun geste ne le fait reculer"],
|
|||
|
|
['PLAN_NON_BLOQUE', 'il ne reste rien à débloquer'],
|
|||
|
|
['NOM_NON_ASSOCIE', "un refus du CSV relayé par importerParticipants : il se lève dans le fichier ou l'association des colonnes"],
|
|||
|
|
['RESERVATION_ABSENTE', "la réservation visée est absente, aucune commande ne la produit, et l'interface n'offre le retrait que sur une réservation présente"],
|
|||
|
|
['TITRE_ABSENT', "le titre visé est absent, aucune commande ne le produit, et l'interface n'offre le retrait que sur un titre présent"],
|
|||
|
|
['CONVERSION_IMPOSSIBLE', "les réservations « tour » visées sont absentes, aucune commande ne les produit, et l'interface n'offre la conversion que sur des réservations présentes"],
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Le geste qui pose deux places de plus à la table 2, par son champ.
|
|||
|
|
const QUATRE_PLACES_A_LA_TABLE_2 = ['changerPlacesTable', { id: 2, sieges: '4', origine: 'champ' }];
|
|||
|
|
|
|||
|
|
// La personne libre de la table 2 au tour 1, retirée dans la réserve du tour.
|
|||
|
|
const RETIRER_UN_LIBRE_DE_LA_TABLE = (idTable) => [
|
|||
|
|
'retirerDuTour',
|
|||
|
|
(charge) => ({ tour: 1, participant: libresA(charge, 1, idTable)[0] }),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Par commande : accepte, refuse, sansEffet, chacun { args, avant?, sur? }
|
|||
|
|
* — args, un objet ou une fonction de la charge où l'exemple s'exécute ;
|
|||
|
|
* avant, des gestes [nom, args] qui le préparent ; sur, 'vide' pour partir
|
|||
|
|
* de l'événement vide plutôt que de la charge témoin —, ou une chaîne, la
|
|||
|
|
* raison de son absence. refuse porte code, le refus attendu, et remede,
|
|||
|
|
* le remède attendu, quand l'exemple le fixe. personne, une
|
|||
|
|
* fonction des arguments de l'exemple accepté : l'identifiant de la personne
|
|||
|
|
* que son libellé nomme.
|
|||
|
|
*/
|
|||
|
|
const EXEMPLES = new Map([
|
|||
|
|
[
|
|||
|
|
'ajouterParticipant',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { champs: { nom: 'Ombrelle', prenom: 'Iris' } } },
|
|||
|
|
refuse: { args: { champs: { nom: ' ' } }, code: 'NOM_REQUIS' },
|
|||
|
|
sansEffet: 'ajouter crée toujours une personne, sous un identifiant neuf',
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'modifierParticipant',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { id: 2, champs: { notes: 'Arrive au deuxième tour' } } },
|
|||
|
|
personne: () => 2,
|
|||
|
|
refuse: { args: { id: 2, champs: { nom: '' } }, code: 'NOM_REQUIS' },
|
|||
|
|
sansEffet: { args: (charge) => ({ id: 2, champs: { nom: participantDe(charge, 2).nom } }) },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'exclureParticipant',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { id: 2 } },
|
|||
|
|
personne: () => 2,
|
|||
|
|
refuse: { args: { id: 999 }, code: 'PARTICIPANT_INCONNU' },
|
|||
|
|
sansEffet: { avant: [['exclureParticipant', { id: 2 }]], args: { id: 2 } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'reintegrerParticipant',
|
|||
|
|
{
|
|||
|
|
accepte: { avant: [['exclureParticipant', { id: 2 }]], args: { id: 2 } },
|
|||
|
|
personne: () => 2,
|
|||
|
|
refuse: {
|
|||
|
|
avant: [
|
|||
|
|
['exclureParticipant', { id: 1 }],
|
|||
|
|
['reserver', { participant: 3, table: 1, siege: 1, portee: 'tous', tour: null }],
|
|||
|
|
],
|
|||
|
|
args: { id: 1 },
|
|||
|
|
code: 'PLACE_RESERVEE',
|
|||
|
|
},
|
|||
|
|
sansEffet: { args: { id: 2 } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'supprimerParticipant',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { id: 2 } },
|
|||
|
|
personne: () => 2,
|
|||
|
|
refuse: { args: { id: 999 }, code: 'PARTICIPANT_INCONNU' },
|
|||
|
|
sansEffet: 'supprimer retire toujours une personne ; une personne absente est PARTICIPANT_INCONNU',
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'importerParticipants',
|
|||
|
|
{
|
|||
|
|
accepte: {
|
|||
|
|
args: (charge) => ({
|
|||
|
|
apercu: apercevoir('nom;prenom\nPervenche;Théo\n', { participants: charge.participants }),
|
|||
|
|
mode: 'ajouter',
|
|||
|
|
}),
|
|||
|
|
},
|
|||
|
|
refuse: {
|
|||
|
|
args: (charge) => ({
|
|||
|
|
apercu: apercevoir('prenom;courriel\nIris;iris@exemple.test\n', { participants: charge.participants }),
|
|||
|
|
mode: 'ajouter',
|
|||
|
|
}),
|
|||
|
|
code: 'NOM_NON_ASSOCIE',
|
|||
|
|
},
|
|||
|
|
sansEffet: {
|
|||
|
|
args: (charge) => {
|
|||
|
|
const { nom, prenom } = participantDe(charge, 2);
|
|||
|
|
const texte = `nom;prenom\n${nom};${prenom ?? ''}\n`;
|
|||
|
|
return { apercu: apercevoir(texte, { participants: charge.participants, mode: 'mettreAJour' }), mode: 'mettreAJour' };
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'changerEtat',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { etat: 'propose' } },
|
|||
|
|
refuse: { sur: 'vide', args: { etat: 'propose' }, code: 'AUCUNE_PROPOSITION' },
|
|||
|
|
sansEffet: { args: { etat: 'retenu' } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'debloquer',
|
|||
|
|
{
|
|||
|
|
accepte: 'debloquer passe par seance.debloquer() sur un plan bloqué, éprouvé à part',
|
|||
|
|
refuse: { args: {}, code: 'PLAN_NON_BLOQUE' },
|
|||
|
|
sansEffet: "débloquer un plan qui n'est pas bloqué est son refus, PLAN_NON_BLOQUE",
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'enregistrerGeneration',
|
|||
|
|
{
|
|||
|
|
accepte: {
|
|||
|
|
args: (charge) => {
|
|||
|
|
const configuration = configurationDepuisCharge(charge);
|
|||
|
|
const { historique } = charge.reglages.generation;
|
|||
|
|
const propositions = rechercher(configuration, { graine: 7, nombre: 1, arret: 200, historique });
|
|||
|
|
return { propositions, configuration, produitVersion: V };
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
refuse:
|
|||
|
|
"ses refus d'avant lancement appartiennent au contrôleur de génération ; la commande ne refuse que " +
|
|||
|
|
"COMPTEUR_SATURE, un compteur au bout de sa règle, qu'aucune charge témoin n'atteint",
|
|||
|
|
sansEffet: { args: (charge) => ({ propositions: [], configuration: configurationDepuisCharge(charge), produitVersion: V }) },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'retenirProposition',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { id: 1 } },
|
|||
|
|
refuse: { args: { id: 99 }, code: 'PROPOSITION_INCONNUE' },
|
|||
|
|
sansEffet: { args: { id: 2 } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'effacerPropositions',
|
|||
|
|
{
|
|||
|
|
accepte: { args: {} },
|
|||
|
|
refuse: "effacer ne refuse rien du domaine : une liste réduite à la proposition du retenu est sans effet",
|
|||
|
|
sansEffet: { avant: [['effacerPropositions', {}]], args: {} },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'creerTable',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { forme: 'carree' } },
|
|||
|
|
refuse: "créer une table ne refuse que COMPTEUR_SATURE, un compteur au bout de sa règle, qu'aucune charge témoin n'atteint",
|
|||
|
|
sansEffet: 'créer ajoute toujours une table, sous un identifiant neuf',
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'supprimerTable',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { id: 4 } },
|
|||
|
|
refuse: "supprimer une table ne refuse rien du domaine ; une table inconnue est une faute de l'appelant (TypeError)",
|
|||
|
|
sansEffet: 'supprimer retire toujours une table',
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'deplacerTable',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { id: 2, position: { x: 420, y: -60 } } },
|
|||
|
|
refuse: 'une table se pose partout : déplacer ne refuse rien du domaine',
|
|||
|
|
sansEffet: { args: (charge) => ({ id: 2, position: { ...tableDe(charge, 2).position } }) },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'changerPlacesTable',
|
|||
|
|
{
|
|||
|
|
accepte: { args: QUATRE_PLACES_A_LA_TABLE_2[1] },
|
|||
|
|
refuse: { args: { id: 2, sieges: '2', origine: 'poignee' }, code: 'SOUS_OCCUPES' },
|
|||
|
|
sansEffet: { avant: [QUATRE_PLACES_A_LA_TABLE_2], args: QUATRE_PLACES_A_LA_TABLE_2[1] },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'realignerTable',
|
|||
|
|
{
|
|||
|
|
accepte: { avant: [QUATRE_PLACES_A_LA_TABLE_2], args: { id: 2 } },
|
|||
|
|
refuse: {
|
|||
|
|
avant: [QUATRE_PLACES_A_LA_TABLE_2, ['poserTitre', { table: 2, siege: 4, libelle: AUTRE_TITRE }]],
|
|||
|
|
args: { id: 2 },
|
|||
|
|
code: 'SIEGES_RETIRES',
|
|||
|
|
},
|
|||
|
|
sansEffet: { args: { id: 2 } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'changerDefaut',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { sieges: '4', realigner: false } },
|
|||
|
|
refuse: {
|
|||
|
|
avant: [['poserTitre', { table: 2, siege: 3, libelle: AUTRE_TITRE }]],
|
|||
|
|
args: { sieges: '2', realigner: false },
|
|||
|
|
code: 'SIEGES_RETIRES',
|
|||
|
|
},
|
|||
|
|
sansEffet: { args: { sieges: '3', realigner: false } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'changerTours',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { tours: '3' } },
|
|||
|
|
refuse: { args: { tours: '0' }, code: 'MOINS_D_UN_TOUR' },
|
|||
|
|
sansEffet: { args: { tours: '4' } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'modifierReglage',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { cle: 'nouveauxVoisins', valeur: false } },
|
|||
|
|
refuse: "une clé ou une valeur hors de leur domaine est une faute du code (TypeError) : l'interface n'offre que les réglages admis",
|
|||
|
|
sansEffet: { args: { cle: 'attribuerSieges', valeur: false } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'reserver',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { participant: 5, table: 2, siege: null, portee: 'tour', tour: 2 } },
|
|||
|
|
personne: () => 5,
|
|||
|
|
refuse: { args: { participant: 3, table: 1, siege: 1, portee: 'tous', tour: null }, code: 'PLACE_RESERVEE' },
|
|||
|
|
sansEffet: { args: { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'retirerReservation',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { participant: 1, table: 1, portee: 'tous', tour: null } },
|
|||
|
|
personne: () => 1,
|
|||
|
|
refuse: { args: { participant: 2, table: 1, portee: 'tous', tour: null }, code: 'RESERVATION_ABSENTE' },
|
|||
|
|
sansEffet: 'une réservation absente est RESERVATION_ABSENTE ; retirer en retire toujours une',
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'deplacerReservation',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 1, siege: 2 } } },
|
|||
|
|
personne: () => 1,
|
|||
|
|
refuse: {
|
|||
|
|
avant: [['reserver', { participant: 5, table: 2, siege: 2, portee: 'tous', tour: null }]],
|
|||
|
|
args: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: 2 } },
|
|||
|
|
code: 'PLACE_RESERVEE',
|
|||
|
|
},
|
|||
|
|
sansEffet: { args: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 1, siege: 1 } } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'poserTitre',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { table: 2, siege: null, libelle: AUTRE_TITRE } },
|
|||
|
|
refuse: { args: { table: 1, siege: 1, libelle: AUTRE_TITRE }, code: 'PLACE_TITREE' },
|
|||
|
|
sansEffet: 'une place déjà titrée refuse PLACE_TITREE : poser ajoute toujours un titre',
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'retirerTitre',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { table: 1, siege: 1 } },
|
|||
|
|
refuse: { args: { table: 2, siege: 1 }, code: 'TITRE_ABSENT' },
|
|||
|
|
sansEffet: 'un titre absent est TITRE_ABSENT ; retirer en retire toujours un',
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'designer',
|
|||
|
|
{
|
|||
|
|
accepte: { args: { participant: 5, table: 2, siege: null, libelle: AUTRE_TITRE } },
|
|||
|
|
personne: () => 5,
|
|||
|
|
refuse: { args: { participant: 5, table: 1, siege: 1, libelle: AUTRE_TITRE }, code: 'PLACE_RESERVEE' },
|
|||
|
|
sansEffet: { args: { participant: 1, table: 1, siege: 1, libelle: TITRE } },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'convertirEnTous',
|
|||
|
|
{
|
|||
|
|
accepte: {
|
|||
|
|
avant: [1, 2, 3, 4].map((tour) => ['reserver', { participant: 5, table: 2, siege: null, portee: 'tour', tour }]),
|
|||
|
|
args: { participant: 5, table: 2 },
|
|||
|
|
},
|
|||
|
|
personne: () => 5,
|
|||
|
|
refuse: { args: { participant: 5, table: 2 }, code: 'CONVERSION_IMPOSSIBLE' },
|
|||
|
|
sansEffet: 'hors des conversions possibles, CONVERSION_IMPOSSIBLE ; convertir remplace toujours les réservations « tour » par une',
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'deplacerDansRetenu',
|
|||
|
|
{
|
|||
|
|
accepte: {
|
|||
|
|
avant: [['creerTable', { forme: 'ronde' }]],
|
|||
|
|
args: (charge) => ({ tour: 1, participant: libresA(charge, 1, 2)[0], table: derniereTable(charge), siege: null }),
|
|||
|
|
},
|
|||
|
|
personne: (args) => args.participant,
|
|||
|
|
refuse: { args: (charge) => ({ tour: 1, participant: libresA(charge, 1, 2)[0], table: 3, siege: null }), code: 'TABLE_PLEINE' },
|
|||
|
|
sansEffet: { args: (charge) => ({ tour: 1, participant: libresA(charge, 1, 2)[0], table: 2, siege: null }) },
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'echangerDansRetenu',
|
|||
|
|
{
|
|||
|
|
accepte: { args: (charge) => ({ tour: 1, a: libresA(charge, 1, 2)[0], b: libresA(charge, 1, 3)[0] }) },
|
|||
|
|
personne: (args) => args.a,
|
|||
|
|
refuse: { args: (charge) => ({ tour: 1, a: 1, b: libresA(charge, 1, 2)[0] }), code: 'RESERVEE_AILLEURS' },
|
|||
|
|
sansEffet: {
|
|||
|
|
avant: [RETIRER_UN_LIBRE_DE_LA_TABLE(2), RETIRER_UN_LIBRE_DE_LA_TABLE(3)],
|
|||
|
|
args: (charge) => {
|
|||
|
|
const [a, b] = charge.retenu.placement[0].reserve;
|
|||
|
|
return { tour: 1, a, b };
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
[
|
|||
|
|
'retirerDuTour',
|
|||
|
|
{
|
|||
|
|
accepte: { args: (charge) => ({ tour: 1, participant: libresA(charge, 1, 2)[0] }) },
|
|||
|
|
personne: (args) => args.participant,
|
|||
|
|
refuse: {
|
|||
|
|
args: { tour: 1, participant: 1 },
|
|||
|
|
code: 'INVARIANT_VIOLE',
|
|||
|
|
remede: { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tous', tour: null } },
|
|||
|
|
},
|
|||
|
|
sansEffet: {
|
|||
|
|
avant: [RETIRER_UN_LIBRE_DE_LA_TABLE(2)],
|
|||
|
|
args: (charge) => ({ tour: 1, participant: charge.retenu.placement[0].reserve[0] }),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Les retouches du retenu, que le vérificateur d'invariants juge (§ 14.12).
|
|||
|
|
const RETOUCHES = ['deplacerDansRetenu', 'echangerDansRetenu', 'retirerDuTour'];
|
|||
|
|
|
|||
|
|
// Les arguments d'un exemple sur la charge où il s'exécute.
|
|||
|
|
const argumentsDe = (args, charge) => (typeof args === 'function' ? args(charge) : structuredClone(args));
|
|||
|
|
|
|||
|
|
// --- Le montage --------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function creerIdentifiants(prefixe) {
|
|||
|
|
let n = 0;
|
|||
|
|
return () => {
|
|||
|
|
n += 1;
|
|||
|
|
return `${prefixe}-${n}`;
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// La façade sur un système neuf, branchée sur une séance comme App la
|
|||
|
|
// branche : changerRacine crée la séance sur la racine reçue.
|
|||
|
|
function monterFacade() {
|
|||
|
|
const fs = creerFichiersSimules();
|
|||
|
|
const horloge = creerHorlogePilotee({ depart: DEPART });
|
|||
|
|
const identifiant = creerIdentifiants('montage');
|
|||
|
|
const monte = { fs, horloge, seance: null, racine: null };
|
|||
|
|
monte.facade = creerFacade({
|
|||
|
|
fs,
|
|||
|
|
horloge,
|
|||
|
|
identifiant,
|
|||
|
|
produitVersion: V,
|
|||
|
|
demonstrations: DEMONSTRATIONS,
|
|||
|
|
ouvert: () => monte.seance?.ouvert?.base ?? null,
|
|||
|
|
ouvrir: (base) => monte.seance.ouvrir(base),
|
|||
|
|
fermer: () => monte.seance.fermer(),
|
|||
|
|
creer: (champs) => monte.seance.creer(champs),
|
|||
|
|
racine: () => monte.racine,
|
|||
|
|
changerRacine: async (nouvelle) => {
|
|||
|
|
monte.racine = nouvelle;
|
|||
|
|
monte.seance = creerSeance({
|
|||
|
|
fs,
|
|||
|
|
racine: nouvelle,
|
|||
|
|
horloge,
|
|||
|
|
identifiant,
|
|||
|
|
produitVersion: V,
|
|||
|
|
planifier: horloge.planifier,
|
|||
|
|
annuler: horloge.annuler,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
return monte;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Les fichiers de l'événement base sur le disque : [chemin, texte].
|
|||
|
|
function fichiersDe(fs, racine, base) {
|
|||
|
|
return [SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal]
|
|||
|
|
.map((suffixe) => [base + suffixe, fs.contenu(racine.id, base + suffixe)])
|
|||
|
|
.filter(([, texte]) => texte !== null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let departs = null;
|
|||
|
|
|
|||
|
|
// Les deux événements de départ, construits au premier appel puis partagés :
|
|||
|
|
// { vide, temoin }, chacun { racine, base, fichiers }.
|
|||
|
|
function departsPrets() {
|
|||
|
|
departs ??= (async () => {
|
|||
|
|
const monte = monterFacade();
|
|||
|
|
const { facade, fs, horloge } = monte;
|
|||
|
|
await facade.demarrer();
|
|||
|
|
const { base: baseVide } = await facade.creer({ nom: NOM_VIDE });
|
|||
|
|
const vide = { racine: monte.racine, base: baseVide, fichiers: fichiersDe(fs, monte.racine, baseVide) };
|
|||
|
|
|
|||
|
|
const { base } = await facade.chargerDemonstration('petite');
|
|||
|
|
const { seance } = monte;
|
|||
|
|
await seance.passerEnEcriture();
|
|||
|
|
await seance.executer('designer', { participant: 1, table: 1, siege: null, libelle: TITRE });
|
|||
|
|
const controleur = creerControleurGeneration({ seance, executeur: creerExecuteurDirect(), horloge, produitVersion: V });
|
|||
|
|
const { historique } = seance.charge.reglages.generation;
|
|||
|
|
await controleur.generer({ graine: 1, nombre: 2, arret: 2000, historique }, { confirmee: true });
|
|||
|
|
await seance.executer('retenirProposition', { id: 2 });
|
|||
|
|
await seance.passerEnLecture();
|
|||
|
|
|
|||
|
|
const { charge } = seance;
|
|||
|
|
assert.deepEqual(
|
|||
|
|
charge.propositions.map(({ id }) => id),
|
|||
|
|
[1, 2],
|
|||
|
|
);
|
|||
|
|
assert.equal(charge.retenu.proposition, 2);
|
|||
|
|
assert.equal(charge.evenement.etat, 'retenu');
|
|||
|
|
assert.ok(charge.prochainsIds.proposition > Math.max(...charge.propositions.map(({ id }) => id), charge.retenu.proposition));
|
|||
|
|
assert.deepEqual(violationsDuRetenu(charge), []);
|
|||
|
|
const temoin = { racine: monte.racine, base, fichiers: fichiersDe(fs, monte.racine, base) };
|
|||
|
|
return { vide, temoin };
|
|||
|
|
})();
|
|||
|
|
return departs;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Une séance neuve, sur un système neuf qui porte une copie des fichiers du
|
|||
|
|
// départ, l'événement ouvert en lecture ; en écriture sur demande.
|
|||
|
|
async function ouvrirCopie(depart, { ecriture = false } = {}) {
|
|||
|
|
const fs = creerFichiersSimules();
|
|||
|
|
for (const [chemin, texte] of depart.fichiers) fs.deposer(depart.racine.id, chemin, texte);
|
|||
|
|
const horloge = creerHorlogePilotee({ depart: DEPART });
|
|||
|
|
const seance = creerSeance({
|
|||
|
|
fs,
|
|||
|
|
racine: depart.racine,
|
|||
|
|
horloge,
|
|||
|
|
identifiant: creerIdentifiants('copie'),
|
|||
|
|
produitVersion: V,
|
|||
|
|
planifier: horloge.planifier,
|
|||
|
|
annuler: horloge.annuler,
|
|||
|
|
});
|
|||
|
|
await seance.ouvrir(depart.base);
|
|||
|
|
if (ecriture) await seance.passerEnEcriture();
|
|||
|
|
return { fs, seance, racine: depart.racine, base: depart.base };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Le même dossier rouvert par une autre séance, en lecture : l'avertissement
|
|||
|
|
// d'ouverture se calcule sur le fichier écrit, sans l'état de la première.
|
|||
|
|
async function rouvrir({ fs, racine, base }, prefixe) {
|
|||
|
|
const horloge = creerHorlogePilotee({ depart: DEPART });
|
|||
|
|
const seance = creerSeance({
|
|||
|
|
fs,
|
|||
|
|
racine,
|
|||
|
|
horloge,
|
|||
|
|
identifiant: creerIdentifiants(prefixe),
|
|||
|
|
produitVersion: V,
|
|||
|
|
planifier: horloge.planifier,
|
|||
|
|
annuler: horloge.annuler,
|
|||
|
|
});
|
|||
|
|
await seance.ouvrir(base);
|
|||
|
|
return seance;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// La même copie, son plan bloqué par changerEtat : la séance revient en
|
|||
|
|
// lecture.
|
|||
|
|
async function ouvrirBloque(depart) {
|
|||
|
|
const ouvert = await ouvrirCopie(depart, { ecriture: true });
|
|||
|
|
await ouvert.seance.executer('changerEtat', { etat: 'bloque' });
|
|||
|
|
assert.equal(ouvert.seance.mode, 'lecture');
|
|||
|
|
assert.equal(ouvert.seance.charge.evenement.etat, 'bloque');
|
|||
|
|
return ouvert;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// La trace des écritures de l'état et du journal : [primitive, chemin,
|
|||
|
|
// sorte de ligne] ; la sorte, pour une ligne ajoutée au journal.
|
|||
|
|
function tracer(fs) {
|
|||
|
|
const trace = [];
|
|||
|
|
const ajouterLigne = fs.ajouterLigne;
|
|||
|
|
fs.ajouterLigne = (racine, chemin, ligne, ...reste) => {
|
|||
|
|
trace.push(['ajouterLigne', chemin, JSON.parse(ligne).type]);
|
|||
|
|
return ajouterLigne(racine, chemin, ligne, ...reste);
|
|||
|
|
};
|
|||
|
|
const ecrireAtomique = fs.ecrireAtomique;
|
|||
|
|
fs.ecrireAtomique = (racine, chemin, ...reste) => {
|
|||
|
|
trace.push(['ecrireAtomique', chemin, null]);
|
|||
|
|
return ecrireAtomique(racine, chemin, ...reste);
|
|||
|
|
};
|
|||
|
|
return trace;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// La trace réduite à ce qui porte l'entrée, le .precedent et l'état, dans
|
|||
|
|
// l'ordre : 'entree' pour une ligne d'entrée ou le journal réécrit entier.
|
|||
|
|
function ordreDesEcritures(trace, base) {
|
|||
|
|
const sortes = [];
|
|||
|
|
for (const [primitive, chemin, sorte] of trace) {
|
|||
|
|
if (chemin === base + SUFFIXES.journal && (primitive === 'ecrireAtomique' || sorte === 'entree')) sortes.push('entree');
|
|||
|
|
if (chemin === base + SUFFIXES.precedent) sortes.push('precedent');
|
|||
|
|
if (chemin === base + SUFFIXES.etat) sortes.push('etat');
|
|||
|
|
}
|
|||
|
|
return sortes;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const journalDe = ({ fs, racine, base }) => lireJournal(fs.contenu(racine.id, base + SUFFIXES.journal));
|
|||
|
|
const chargeEcrite = ({ fs, racine, base }) => analyser(fs.contenu(racine.id, base + SUFFIXES.etat)).charge;
|
|||
|
|
|
|||
|
|
// Le refus que rejette la promesse, null quand elle aboutit.
|
|||
|
|
async function refusDe(promesse) {
|
|||
|
|
try {
|
|||
|
|
await promesse;
|
|||
|
|
return null;
|
|||
|
|
} catch (erreur) {
|
|||
|
|
return erreur;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Exécute les gestes qui préparent un exemple, chacun sur la charge courante.
|
|||
|
|
async function preparer(seance, avant = []) {
|
|||
|
|
for (const [nom, args] of avant) await seance.executer(nom, argumentsDe(args, seance.charge));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const invariantsAvertis = (seance) => seance.etat().avertissements.filter(({ code }) => code === 'INVARIANT_VIOLE');
|
|||
|
|
|
|||
|
|
// --- La table ----------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe('registre : la table des exemples couvre chaque commande', () => {
|
|||
|
|
test('le registre porte des commandes, et chacune a son entrée, un exemple ou la raison de son absence', () => {
|
|||
|
|
const noms = [...COMMANDES.keys()];
|
|||
|
|
assert.ok(noms.length > 0, 'le registre COMMANDES est vide');
|
|||
|
|
assert.deepEqual(
|
|||
|
|
noms.filter((nom) => !EXEMPLES.has(nom)),
|
|||
|
|
[],
|
|||
|
|
'commandes du registre sans entrée dans la table',
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
[...EXEMPLES.keys()].filter((nom) => !COMMANDES.has(nom)),
|
|||
|
|
[],
|
|||
|
|
'entrées de la table hors du registre',
|
|||
|
|
);
|
|||
|
|
const incompletes = [];
|
|||
|
|
for (const [nom, exemple] of EXEMPLES) {
|
|||
|
|
for (const cle of ['accepte', 'refuse', 'sansEffet']) {
|
|||
|
|
const valeur = exemple[cle];
|
|||
|
|
const donne = typeof valeur === 'string' ? valeur.trim() !== '' : valeur?.args !== undefined;
|
|||
|
|
if (!donne) incompletes.push(`${nom}.${cle}`);
|
|||
|
|
}
|
|||
|
|
if (typeof exemple.refuse === 'object' && typeof exemple.refuse.code !== 'string') incompletes.push(`${nom}.refuse.code`);
|
|||
|
|
}
|
|||
|
|
assert.deepEqual(incompletes, []);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// --- En lecture, et sur un plan bloqué ------------------------------------------
|
|||
|
|
|
|||
|
|
// Pour chaque commande hors debloquer, le refus que la séance lève et le
|
|||
|
|
// compte des écritures : [nom, problème] pour chaque écart.
|
|||
|
|
async function balayerLesRefus(ouvert, attendu) {
|
|||
|
|
const ecarts = [];
|
|||
|
|
const temoin = chargeEcrite(await ouvrirCopie((await departsPrets()).temoin));
|
|||
|
|
const noms = [...COMMANDES.keys()].filter((nom) => nom !== 'debloquer');
|
|||
|
|
assert.ok(noms.length > 0);
|
|||
|
|
for (const nom of noms) {
|
|||
|
|
const exemple = EXEMPLES.get(nom);
|
|||
|
|
const source = typeof exemple?.accepte === 'object' ? exemple.accepte : exemple?.refuse;
|
|||
|
|
let args = {};
|
|||
|
|
try {
|
|||
|
|
args = argumentsDe(source?.args ?? {}, temoin);
|
|||
|
|
} catch {
|
|||
|
|
args = {};
|
|||
|
|
}
|
|||
|
|
const ecritures = ouvert.fs.compteurs.ecritures;
|
|||
|
|
const erreur = await refusDe(ouvert.seance.executer(nom, args));
|
|||
|
|
const ecart = attendu(erreur);
|
|||
|
|
if (ecart !== null) ecarts.push([nom, ecart]);
|
|||
|
|
if (ouvert.fs.compteurs.ecritures !== ecritures) ecarts.push([nom, `${ouvert.fs.compteurs.ecritures - ecritures} écriture(s)`]);
|
|||
|
|
}
|
|||
|
|
return ecarts;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const attenduModeLecture = (erreur) => {
|
|||
|
|
if (erreur === null) return 'accepté en lecture';
|
|||
|
|
if (erreur.code !== 'MODE_LECTURE') return `refusé ${erreur.code} au lieu de MODE_LECTURE`;
|
|||
|
|
return JSON.stringify(erreur.details.remede) === JSON.stringify({ geste: 'modifier' }) ? null : 'remède autre que modifier';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const attenduBloque = (erreur) => {
|
|||
|
|
if (erreur === null) return 'accepté sur un plan bloqué';
|
|||
|
|
if (erreur.code !== 'LECTURE_SEULE' || erreur.details.raison !== 'BLOQUE') {
|
|||
|
|
return `refusé ${erreur.code} ${JSON.stringify(erreur.details.raison ?? null)} au lieu de LECTURE_SEULE BLOQUE`;
|
|||
|
|
}
|
|||
|
|
return JSON.stringify(erreur.details.remede) === JSON.stringify({ geste: 'debloquer' }) ? null : 'remède autre que debloquer';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
describe('registre : en lecture et sur un plan bloqué, aucune commande n’écrit (§ 2.1, § 8.4, § 9)', () => {
|
|||
|
|
test('en lecture, sur un événement vide, chaque commande est refusée MODE_LECTURE sans écriture', async () => {
|
|||
|
|
const ouvert = await ouvrirCopie((await departsPrets()).vide);
|
|||
|
|
assert.deepEqual(await balayerLesRefus(ouvert, attenduModeLecture), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('en lecture, sur la charge témoin retenue, chaque commande est refusée MODE_LECTURE sans écriture', async () => {
|
|||
|
|
const ouvert = await ouvrirCopie((await departsPrets()).temoin);
|
|||
|
|
assert.deepEqual(await balayerLesRefus(ouvert, attenduModeLecture), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('sur un plan bloqué vide, toujours en lecture, chaque commande est refusée LECTURE_SEULE BLOQUE sans écriture', async () => {
|
|||
|
|
const ouvert = await ouvrirBloque((await departsPrets()).vide);
|
|||
|
|
assert.deepEqual(await balayerLesRefus(ouvert, attenduBloque), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('sur la charge témoin bloquée, toujours en lecture, chaque commande est refusée LECTURE_SEULE BLOQUE sans écriture', async () => {
|
|||
|
|
const ouvert = await ouvrirBloque((await departsPrets()).temoin);
|
|||
|
|
assert.deepEqual(await balayerLesRefus(ouvert, attenduBloque), []);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('registre : debloquer (§ 8.5, § 9)', () => {
|
|||
|
|
test('seance.debloquer() sur un plan bloqué prend le verrou, écrit une entrée et un jalon, et laisse la séance en écriture', async () => {
|
|||
|
|
const ouvert = await ouvrirBloque((await departsPrets()).temoin);
|
|||
|
|
const { fs, seance, racine, base } = ouvert;
|
|||
|
|
assert.equal(fs.contenu(racine.id, base + SUFFIXES.verrou), null);
|
|||
|
|
const journal = journalDe(ouvert);
|
|||
|
|
const trace = tracer(fs);
|
|||
|
|
await seance.debloquer();
|
|||
|
|
const verrou = fs.contenu(racine.id, base + SUFFIXES.verrou);
|
|||
|
|
assert.notEqual(verrou, null, 'aucun verrou pris');
|
|||
|
|
assert.equal(JSON.parse(verrou).seance, seance.id);
|
|||
|
|
const apres = journalDe(ouvert);
|
|||
|
|
assert.equal(apres.entrees.length, journal.entrees.length + 1);
|
|||
|
|
assert.equal(apres.jalons.length, journal.jalons.length + 1);
|
|||
|
|
assert.deepEqual(ordreDesEcritures(trace, base), ['entree', 'precedent', 'etat']);
|
|||
|
|
assert.equal(seance.mode, 'ecriture');
|
|||
|
|
assert.equal(chargeEcrite(ouvert).evenement.etat, 'retenu');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// --- En écriture : l'exemple accepté -------------------------------------------
|
|||
|
|
|
|||
|
|
describe('registre : en écriture, un exemple accepté écrit une entrée, et défaire la rend (§ 8.2, § 8.3)', () => {
|
|||
|
|
const acceptes = [...EXEMPLES].filter(([, exemple]) => typeof exemple.accepte === 'object');
|
|||
|
|
assert.ok(acceptes.length > 0);
|
|||
|
|
for (const [nom, exemple] of acceptes) {
|
|||
|
|
test(`${nom} : une entrée, le journal d'abord, l'état reconstruit, puis défaire`, async () => {
|
|||
|
|
const departsCourants = await departsPrets();
|
|||
|
|
const ouvert = await ouvrirCopie(exemple.accepte.sur === 'vide' ? departsCourants.vide : departsCourants.temoin, {
|
|||
|
|
ecriture: true,
|
|||
|
|
});
|
|||
|
|
const { fs, seance, base } = ouvert;
|
|||
|
|
await preparer(seance, exemple.accepte.avant);
|
|||
|
|
const avant = structuredClone(seance.charge);
|
|||
|
|
const journalAvant = journalDe(ouvert);
|
|||
|
|
const args = argumentsDe(exemple.accepte.args, avant);
|
|||
|
|
const trace = tracer(fs);
|
|||
|
|
|
|||
|
|
const rendu = await seance.executer(nom, args);
|
|||
|
|
assert.equal(typeof rendu.libelle, 'string');
|
|||
|
|
const journal = journalDe(ouvert);
|
|||
|
|
assert.equal(journal.entrees.length, journalAvant.entrees.length + 1, 'une entrée exactement');
|
|||
|
|
assert.deepEqual(ordreDesEcritures(trace, base), ['entree', 'precedent', 'etat']);
|
|||
|
|
const derniere = journal.entrees.at(-1);
|
|||
|
|
const ecrite = chargeEcrite(ouvert);
|
|||
|
|
assert.equal(serialiserCharge(reconstruire(journal, derniere.revision)), serialiserCharge(ecrite));
|
|||
|
|
assert.equal(seance.mode, 'ecriture');
|
|||
|
|
|
|||
|
|
// Aucune commande ne répartit : les réservations ajoutées tiennent à
|
|||
|
|
// une seule place (§ 5.8, § 17 point 3).
|
|||
|
|
const connues = new Set(avant.reservations.map((r) => JSON.stringify(r)));
|
|||
|
|
const ajoutees = ecrite.reservations.filter((r) => !connues.has(JSON.stringify(r)));
|
|||
|
|
assert.ok(new Set(ajoutees.map(({ table, siege }) => `${table}:${siege}`)).size <= 1, JSON.stringify(ajoutees));
|
|||
|
|
|
|||
|
|
// Une retouche n'ajoute aucune violation au retenu reçu (§ 14.12).
|
|||
|
|
if (RETOUCHES.includes(nom)) assert.deepEqual(violationsNouvelles(avant, ecrite), []);
|
|||
|
|
|
|||
|
|
await seance.defaire();
|
|||
|
|
const defait = journalDe(ouvert);
|
|||
|
|
assert.equal(defait.entrees.length, journal.entrees.length + 1, 'une entrée de retour');
|
|||
|
|
assert.deepEqual(defait.entrees.slice(0, journal.entrees.length), journal.entrees, 'le journal ne perd aucune entrée');
|
|||
|
|
assert.equal(defait.entrees.at(-1).sens, 'defaire');
|
|||
|
|
const rendue = seance.charge;
|
|||
|
|
for (const cle of ['participant', 'table', 'proposition']) {
|
|||
|
|
assert.equal(rendue.prochainsIds[cle], Math.max(avant.prochainsIds[cle], ecrite.prochainsIds[cle]), cle);
|
|||
|
|
}
|
|||
|
|
assert.equal(serialiserCharge(rendue), serialiserCharge({ ...avant, prochainsIds: rendue.prochainsIds }));
|
|||
|
|
|
|||
|
|
// Le libellé figé ne suit pas un renommage (§ 8.2).
|
|||
|
|
if (exemple.personne !== undefined) {
|
|||
|
|
const id = exemple.personne(args);
|
|||
|
|
const personne = participantDe(rendue, id);
|
|||
|
|
assert.ok(derniere.libelle.includes(nomAffiche(personne)), `${derniere.libelle} ne nomme pas ${nomAffiche(personne)}`);
|
|||
|
|
await seance.executer('modifierParticipant', { id, champs: { nom: 'Grisaille', prenom: 'Ondine' } });
|
|||
|
|
const renomme = journalDe(ouvert).entrees.find(({ revision }) => revision === derniere.revision);
|
|||
|
|
assert.equal(renomme.libelle, derniere.libelle);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// --- En écriture : l'exemple refusé et l'exemple sans effet ------------------------------
|
|||
|
|
|
|||
|
|
describe('registre : un exemple refusé n’écrit rien et nomme son remède (§ 2.1, garantie 3)', () => {
|
|||
|
|
const refuses = [...EXEMPLES].filter(([, exemple]) => typeof exemple.refuse === 'object');
|
|||
|
|
assert.ok(refuses.length > 0);
|
|||
|
|
for (const [nom, exemple] of refuses) {
|
|||
|
|
test(`${nom} : refusé ${exemple.refuse.code}, sans écriture`, async () => {
|
|||
|
|
const departsCourants = await departsPrets();
|
|||
|
|
const ouvert = await ouvrirCopie(exemple.refuse.sur === 'vide' ? departsCourants.vide : departsCourants.temoin, {
|
|||
|
|
ecriture: true,
|
|||
|
|
});
|
|||
|
|
const { fs, seance } = ouvert;
|
|||
|
|
await preparer(seance, exemple.refuse.avant);
|
|||
|
|
const args = argumentsDe(exemple.refuse.args, seance.charge);
|
|||
|
|
const ecritures = fs.compteurs.ecritures;
|
|||
|
|
const entrees = journalDe(ouvert).entrees.length;
|
|||
|
|
const erreur = await refusDe(seance.executer(nom, args));
|
|||
|
|
assert.notEqual(erreur, null, 'accepté');
|
|||
|
|
assert.equal(erreur.code, exemple.refuse.code, erreur.message);
|
|||
|
|
if (erreur.code === 'INVARIANT_VIOLE') {
|
|||
|
|
// La forme du refus de génération : le retenu nommé, et les seules
|
|||
|
|
// violations que la retouche ajouterait.
|
|||
|
|
assert.equal(erreur.details.proposition, seance.charge.retenu.proposition);
|
|||
|
|
assert.ok(erreur.details.violations.length > 0);
|
|||
|
|
const connues = new Set(violationsDuRetenu(seance.charge).map((violation) => JSON.stringify(violation)));
|
|||
|
|
assert.deepEqual(
|
|||
|
|
erreur.details.violations.filter((violation) => connues.has(JSON.stringify(violation))),
|
|||
|
|
[],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (Object.hasOwn(exemple.refuse, 'remede')) assert.deepEqual(erreur.details.remede, exemple.refuse.remede);
|
|||
|
|
assert.equal(fs.compteurs.ecritures, ecritures, 'écriture');
|
|||
|
|
assert.equal(journalDe(ouvert).entrees.length, entrees);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test('chaque refus du domaine nomme son remède, hors des refus admis à remède null', async () => {
|
|||
|
|
const ecarts = [];
|
|||
|
|
for (const [nom, exemple] of refuses) {
|
|||
|
|
const departsCourants = await departsPrets();
|
|||
|
|
const { seance } = await ouvrirCopie(exemple.refuse.sur === 'vide' ? departsCourants.vide : departsCourants.temoin, {
|
|||
|
|
ecriture: true,
|
|||
|
|
});
|
|||
|
|
await preparer(seance, exemple.refuse.avant);
|
|||
|
|
const erreur = await refusDe(seance.executer(nom, argumentsDe(exemple.refuse.args, seance.charge)));
|
|||
|
|
if (erreur === null) {
|
|||
|
|
ecarts.push(`${nom} : accepté`);
|
|||
|
|
} else if (!Object.hasOwn(erreur.details ?? {}, 'remede')) {
|
|||
|
|
ecarts.push(`${nom} : ${erreur.code} sans details.remede`);
|
|||
|
|
} else if (erreur.details.remede === null && !REMEDE_NUL_ADMIS.has(erreur.code)) {
|
|||
|
|
ecarts.push(`${nom} : ${erreur.code} au remède null`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
assert.deepEqual(ecarts, []);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('registre : un exemple sans effet est refusé SANS_EFFET {commande}, sans entrée ni état (§ 8.2)', () => {
|
|||
|
|
const sansEffet = [...EXEMPLES].filter(([, exemple]) => typeof exemple.sansEffet === 'object');
|
|||
|
|
assert.ok(sansEffet.length > 0);
|
|||
|
|
for (const [nom, exemple] of sansEffet) {
|
|||
|
|
test(`${nom} : SANS_EFFET, remède null, rien n'écrit`, async () => {
|
|||
|
|
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
|
|||
|
|
const { fs, seance, racine, base } = ouvert;
|
|||
|
|
await preparer(seance, exemple.sansEffet.avant);
|
|||
|
|
const args = argumentsDe(exemple.sansEffet.args, seance.charge);
|
|||
|
|
const ecritures = fs.compteurs.ecritures;
|
|||
|
|
const etat = fs.contenu(racine.id, base + SUFFIXES.etat);
|
|||
|
|
const journal = fs.contenu(racine.id, base + SUFFIXES.journal);
|
|||
|
|
const erreur = await refusDe(seance.executer(nom, args));
|
|||
|
|
assert.notEqual(erreur, null, 'accepté');
|
|||
|
|
assert.equal(erreur.code, 'SANS_EFFET', erreur.message);
|
|||
|
|
assert.deepEqual(erreur.details, { commande: nom, remede: null });
|
|||
|
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
|||
|
|
assert.equal(fs.contenu(racine.id, base + SUFFIXES.etat), etat);
|
|||
|
|
assert.equal(fs.contenu(racine.id, base + SUFFIXES.journal), journal);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// --- Le vérificateur d'invariants -------------------------------------------------
|
|||
|
|
|
|||
|
|
describe('registre : le vérificateur d’invariants juge les retouches et l’ouverture (§ 14.12, § 14.15)', () => {
|
|||
|
|
test("une retouche qui assiérait à un tour plus de personnes qu'une table n'a de places est refusée, sans écriture", async () => {
|
|||
|
|
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
|
|||
|
|
const { fs, seance } = ouvert;
|
|||
|
|
const { charge } = seance;
|
|||
|
|
const personne = libresA(charge, 1, 2)[0];
|
|||
|
|
const rang = charge.retenu.tables.indexOf(3);
|
|||
|
|
assert.equal(charge.retenu.placement[0].sieges[rang].length, tableDe(charge, 3).sieges ?? charge.evenement.siegesParDefaut);
|
|||
|
|
const ecritures = fs.compteurs.ecritures;
|
|||
|
|
const erreur = await refusDe(seance.executer('deplacerDansRetenu', { tour: 1, participant: personne, table: 3, siege: null }));
|
|||
|
|
assert.notEqual(erreur, null, 'accepté');
|
|||
|
|
// Un refus du contrôle de pose de la retouche, ou du vérificateur ; une
|
|||
|
|
// faute du code ou du stockage n'en est pas un.
|
|||
|
|
assert.ok(['TABLE_PLEINE', 'INVARIANT_VIOLE'].includes(erreur.code), erreur.message);
|
|||
|
|
assert.ok(Object.hasOwn(erreur.details, 'remede'), erreur.message);
|
|||
|
|
if (erreur.code === 'INVARIANT_VIOLE') {
|
|||
|
|
assert.equal(erreur.details.proposition, charge.retenu.proposition);
|
|||
|
|
assert.ok(erreur.details.violations.length > 0);
|
|||
|
|
}
|
|||
|
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("retirerDuTour d'une personne non réservée est accepté : une entrée, la personne dans la réserve du tour, aucun INVARIANT_VIOLE", async () => {
|
|||
|
|
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
|
|||
|
|
const { seance } = ouvert;
|
|||
|
|
const personne = libresA(seance.charge, 1, 2)[0];
|
|||
|
|
const entrees = journalDe(ouvert).entrees.length;
|
|||
|
|
await seance.executer('retirerDuTour', { tour: 1, participant: personne });
|
|||
|
|
assert.equal(journalDe(ouvert).entrees.length, entrees + 1);
|
|||
|
|
assert.ok(chargeEcrite(ouvert).retenu.placement[0].reserve.includes(personne));
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('une violation déjà présente, celle d’une dérive, ne bloque pas les retouches, et INVARIANT_VIOLE la tait à l’ouverture comme après défaire, refaire et revenir', async () => {
|
|||
|
|
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
|
|||
|
|
const { seance } = ouvert;
|
|||
|
|
await seance.executer('exclureParticipant', { id: libresA(seance.charge, 1, 4)[0] });
|
|||
|
|
assert.ok(violationsDuRetenu(seance.charge).length > 0, 'la dérive ne laisse aucune violation');
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), [], 'après la dérive');
|
|||
|
|
const revisionDerive = seance.etat().evenement.revision;
|
|||
|
|
const entrees = journalDe(ouvert).entrees.length;
|
|||
|
|
const { charge } = seance;
|
|||
|
|
await seance.executer('echangerDansRetenu', { tour: 1, a: libresA(charge, 1, 2)[0], b: libresA(charge, 1, 3)[0] });
|
|||
|
|
assert.equal(journalDe(ouvert).entrees.length, entrees + 1);
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), [], 'après la retouche');
|
|||
|
|
await seance.passerEnLecture();
|
|||
|
|
|
|||
|
|
const autre = await rouvrir(ouvert, 'derive');
|
|||
|
|
assert.ok(violationsDuRetenu(autre.charge).length > 0, 'la dérive ne survit pas à la réouverture');
|
|||
|
|
assert.deepEqual(invariantsAvertis(autre), [], "à l'ouverture");
|
|||
|
|
await autre.passerEnEcriture();
|
|||
|
|
await autre.defaire();
|
|||
|
|
assert.ok(violationsDuRetenu(autre.charge).length > 0, 'défaire efface la dérive');
|
|||
|
|
assert.deepEqual(invariantsAvertis(autre), [], 'après défaire');
|
|||
|
|
await autre.refaire();
|
|||
|
|
assert.deepEqual(invariantsAvertis(autre), [], 'après refaire');
|
|||
|
|
await autre.revenir(revisionDerive);
|
|||
|
|
assert.ok(violationsDuRetenu(autre.charge).length > 0, 'revenir efface la dérive');
|
|||
|
|
assert.deepEqual(invariantsAvertis(autre), [], 'après revenir');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre en lecture avec INVARIANT_VIOLE, sans écriture, et le garde après défaire, refaire et revenir", async () => {
|
|||
|
|
const { temoin } = await departsPrets();
|
|||
|
|
const source = await ouvrirCopie(temoin);
|
|||
|
|
const charge = structuredClone(source.seance.charge);
|
|||
|
|
const personne = libresA(charge, 1, 2)[0];
|
|||
|
|
const reservation = { participant: personne, table: 3, siege: null, portee: 'tour', tour: 1 };
|
|||
|
|
charge.reservations.push(reservation);
|
|||
|
|
|
|||
|
|
const fs = creerFichiersSimules();
|
|||
|
|
fs.deposerDossier(temoin.racine.id);
|
|||
|
|
const ecrit = await creerDansLeDepot(fs, temoin.racine, charge, {
|
|||
|
|
libelle: libelle('ENTREE_CREATION', { nom: charge.evenement.nom }),
|
|||
|
|
nomJalon: libelle('JALON_CREATION'),
|
|||
|
|
horodatage: '2031-03-14T17:00:00-04:00',
|
|||
|
|
produitVersion: V,
|
|||
|
|
});
|
|||
|
|
const horloge = creerHorlogePilotee({ depart: DEPART });
|
|||
|
|
const seance = creerSeance({
|
|||
|
|
fs,
|
|||
|
|
racine: temoin.racine,
|
|||
|
|
horloge,
|
|||
|
|
identifiant: creerIdentifiants('invariant'),
|
|||
|
|
produitVersion: V,
|
|||
|
|
planifier: horloge.planifier,
|
|||
|
|
annuler: horloge.annuler,
|
|||
|
|
});
|
|||
|
|
const ecritures = fs.compteurs.ecritures;
|
|||
|
|
await seance.ouvrir(ecrit.base);
|
|||
|
|
assert.equal(fs.compteurs.ecritures, ecritures, 'ouvrir écrit');
|
|||
|
|
assert.equal(seance.mode, 'lecture');
|
|||
|
|
const attendu = [
|
|||
|
|
{
|
|||
|
|
code: 'INVARIANT_VIOLE',
|
|||
|
|
details: { proposition: charge.retenu.proposition, violations: [{ code: 'RESERVATION', participant: personne, table: 3, tour: 1 }] },
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), attendu, "à l'ouverture");
|
|||
|
|
|
|||
|
|
await seance.passerEnEcriture();
|
|||
|
|
await seance.executer('retirerReservation', { participant: personne, table: 3, portee: 'tour', tour: 1 });
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), [], 'la réservation retirée');
|
|||
|
|
await seance.defaire();
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), attendu, 'après défaire');
|
|||
|
|
await seance.refaire();
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), [], 'après refaire');
|
|||
|
|
await seance.revenir(1);
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), attendu, 'après revenir');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un retenu dont changerPlacesTable a versé des surnuméraires en réserve s’ouvre sans INVARIANT_VIOLE, et le reste après défaire et refaire', async () => {
|
|||
|
|
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
|
|||
|
|
const { seance } = ouvert;
|
|||
|
|
assert.deepEqual(invariantsAvertis(seance), [], "à l'ouverture de la charge témoin");
|
|||
|
|
await seance.executer('creerTable', { forme: 'ronde' });
|
|||
|
|
await seance.executer('changerPlacesTable', { id: 2, sieges: '2', origine: 'champ' });
|
|||
|
|
const reduite = chargeEcrite(ouvert);
|
|||
|
|
const reserves = reduite.retenu.placement.map(({ reserve }) => reserve.length);
|
|||
|
|
assert.ok(reserves.every((n) => n > 0), `réserves ${JSON.stringify(reserves)}`);
|
|||
|
|
assert.ok(
|
|||
|
|
violationsDuRetenu(reduite, { reserveAutorisee: false }).some(({ code }) => code === 'NON_ASSIS'),
|
|||
|
|
'la réserve ne porte personne que la règle sans réserve refuserait',
|
|||
|
|
);
|
|||
|
|
await seance.passerEnLecture();
|
|||
|
|
|
|||
|
|
const autre = await rouvrir(ouvert, 'reouverture');
|
|||
|
|
assert.deepEqual(invariantsAvertis(autre), [], "à l'ouverture");
|
|||
|
|
await autre.passerEnEcriture();
|
|||
|
|
await autre.defaire();
|
|||
|
|
assert.deepEqual(invariantsAvertis(autre), [], 'après défaire');
|
|||
|
|
await autre.refaire();
|
|||
|
|
assert.deepEqual(invariantsAvertis(autre), [], 'après refaire');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// --- Créer et charger par la façade ------------------------------------------------
|
|||
|
|
|
|||
|
|
describe('registre : créer un événement et charger une démonstration écrivent sans passer par « Modifier » (§ 2.1, étape 1)', () => {
|
|||
|
|
test('créer, par la façade, écrit l’état et son entrée, la séance restant en lecture, sans verrou', async () => {
|
|||
|
|
const monte = monterFacade();
|
|||
|
|
await monte.facade.demarrer();
|
|||
|
|
assert.equal(monte.seance.mode, 'lecture');
|
|||
|
|
const { base } = await monte.facade.creer({ nom: NOM_VIDE });
|
|||
|
|
const { fs, racine, seance } = monte;
|
|||
|
|
assert.equal(seance.mode, 'lecture');
|
|||
|
|
assert.notEqual(fs.contenu(racine.id, base + SUFFIXES.etat), null);
|
|||
|
|
assert.equal(lireJournal(fs.contenu(racine.id, base + SUFFIXES.journal)).entrees.length, 1);
|
|||
|
|
assert.equal(fs.contenu(racine.id, base + SUFFIXES.verrou), null);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('charger une démonstration, par la façade, écrit l’état et son entrée, la séance restant en lecture, sans verrou', async () => {
|
|||
|
|
const monte = monterFacade();
|
|||
|
|
await monte.facade.demarrer();
|
|||
|
|
const { base } = await monte.facade.chargerDemonstration('petite');
|
|||
|
|
const { fs, racine, seance } = monte;
|
|||
|
|
assert.equal(seance.mode, 'lecture');
|
|||
|
|
assert.equal(seance.ouvert.base, base);
|
|||
|
|
assert.notEqual(fs.contenu(racine.id, base + SUFFIXES.etat), null);
|
|||
|
|
assert.equal(lireJournal(fs.contenu(racine.id, base + SUFFIXES.journal)).entrees.length, 1);
|
|||
|
|
assert.equal(fs.contenu(racine.id, base + SUFFIXES.verrou), null);
|
|||
|
|
});
|
|||
|
|
});
|