[ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.
--- FR ---
[ADD] application : séance observable, façade des événements, places
Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.
Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la façade des événements (§ 8.1, § 8.6, § 8.7, § 8.8, § 15,
// § 19.4), toutes sur le système de fichiers d'épreuve, qui compte ses
// lectures et ses écritures, avec l'horloge pilotée. La façade se branche sur
// une séance réelle, que le montage crée sur la racine que reçoit
// changerRacine, comme App la branche ; chaque appel que la façade fait à la
// séance s'inscrit au relevé du montage. 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 { CATALOGUE } from '../demo/catalogue.js' ;
import { LIVREES , texteLivre } from '../demo/livrees.js' ;
[ADD] home: retained plan's collisions and redundancy gap per event
The event list showed no outcome, so comparing events meant opening each.
Each line now shows, for the retained plan only, the cumulated collisions
(pair and tour) and the redundancy gap |target - sum of r(p)|, with an
explanation tooltip; an event without a measurable retained plan shows a
dash. The target is set per event in Propositions, 1000 by default: an
optional settings field, absent in every existing file and read as 1000,
written through one undoable command refused in reading mode. Listing
stays read-only. Checked: 2909 node, 604 browser, 62 long tests.
--- FR ---
[ADD] accueil : collisions et Diff de redondance du retenu par événement
La liste des événements ne montrait aucun résultat : comparer obligeait à
ouvrir chacun. Chaque ligne montre, pour le retenu seul, les collisions
cumulées (paire et tour) et la Diff somme d'appartenance, |cible - somme
de r(p)|, avec une infobulle ; sans retenu mesurable, un tiret. La cible
se règle par événement dans Propositions, 1000 par défaut : champ
facultatif des réglages, absent de tout fichier existant et lu 1000, écrit
par une commande qui se défait et se refuse en lecture. La liste reste en
lecture seule. Vérifié : 2909 node, 604 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 05:35:48 -04:00
import { rechercher } from '../moteur/recherche.js' ;
[ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.
--- FR ---
[ADD] application : séance observable, façade des événements, places
Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.
Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00
import { serialiser } from '../stockage/canonique.js' ;
import { creer as creerDansLeDepot } from '../stockage/depot.js' ;
[ADD] home: retained plan's collisions and redundancy gap per event
The event list showed no outcome, so comparing events meant opening each.
Each line now shows, for the retained plan only, the cumulated collisions
(pair and tour) and the redundancy gap |target - sum of r(p)|, with an
explanation tooltip; an event without a measurable retained plan shows a
dash. The target is set per event in Propositions, 1000 by default: an
optional settings field, absent in every existing file and read as 1000,
written through one undoable command refused in reading mode. Listing
stays read-only. Checked: 2909 node, 604 browser, 62 long tests.
--- FR ---
[ADD] accueil : collisions et Diff de redondance du retenu par événement
La liste des événements ne montrait aucun résultat : comparer obligeait à
ouvrir chacun. Chaque ligne montre, pour le retenu seul, les collisions
cumulées (paire et tour) et la Diff somme d'appartenance, |cible - somme
de r(p)|, avec une infobulle ; sans retenu mesurable, un tiret. La cible
se règle par événement dans Propositions, 1000 par défaut : champ
facultatif des réglages, absent de tout fichier existant et lu 1000, écrit
par une commande qui se défait et se refuse en lecture. La liste reste en
lecture seule. Vérifié : 2909 node, 604 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 05:35:48 -04:00
import { analyser , configurationDepuisCharge , creerCharge } from '../stockage/document.js' ;
[ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.
--- FR ---
[ADD] application : séance observable, façade des événements, places
Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.
Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00
import { ErreurStockage } from '../stockage/erreurs.js' ;
import { lireJournal } from '../stockage/journal.js' ;
import { SUFFIXES } from '../stockage/noms.js' ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
import { examiner } from '../stockage/placements.js' ;
[ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.
--- FR ---
[ADD] application : séance observable, façade des événements, places
Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.
Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00
import { VERSION } from '../version.genere.js' ;
[ADD] home: retained plan's collisions and redundancy gap per event
The event list showed no outcome, so comparing events meant opening each.
Each line now shows, for the retained plan only, the cumulated collisions
(pair and tour) and the redundancy gap |target - sum of r(p)|, with an
explanation tooltip; an event without a measurable retained plan shows a
dash. The target is set per event in Propositions, 1000 by default: an
optional settings field, absent in every existing file and read as 1000,
written through one undoable command refused in reading mode. Listing
stays read-only. Checked: 2909 node, 604 browser, 62 long tests.
--- FR ---
[ADD] accueil : collisions et Diff de redondance du retenu par événement
La liste des événements ne montrait aucun résultat : comparer obligeait à
ouvrir chacun. Chaque ligne montre, pour le retenu seul, les collisions
cumulées (paire et tour) et la Diff somme d'appartenance, |cible - somme
de r(p)|, avec une infobulle ; sans retenu mesurable, un tiret. La cible
se règle par événement dans Propositions, 1000 par défaut : champ
facultatif des réglages, absent de tout fichier existant et lu 1000, écrit
par une commande qui se défait et se refuse en lecture. La liste reste en
lecture seule. Vérifié : 2909 node, 604 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 05:35:48 -04:00
import { appliquerCommande } from './commandes.js' ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
import { evaluer , resumeDuMeilleur , resumeDuRetenu } from './evaluation.js' ;
[ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.
--- FR ---
[ADD] application : séance observable, façade des événements, places
Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.
Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00
import { creerFacade } from './evenements.js' ;
import { creerHorlogePilotee } from './horloge.js' ;
import { libelle } from './libelles.js' ;
import { creerSeance } from './seance.js' ;
const V1 = VERSION . affichee ;
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' ;
const DOC = { id : 'documents' , chemin : DOCUMENTS } ;
const PUBLIE = 'E:\\soirees' ;
const PORTABLE = { id : 'portable' , chemin : 'E:\\soirees\\data' } ;
const SOUS _APPDATA = 'C:\\Users\\Exemple\\AppData\\Local\\Programmes\\Tablee' ;
const PORTABLE _APPDATA = { id : 'portable' , chemin : ` ${ SOUS _APPDATA } \\ data ` } ;
// L'instant de départ de l'horloge pilotée, et le dossier de corbeille qu'il
// date.
const DEPART = '2026-10-06T19:00:00-04:00' ;
const CORBEILLE _DU _DEPART = 'corbeille/2026-10-06_19-00-00' ;
const LUCIOLES = 'Soirée des Lucioles' ;
const CERFS = 'Soirée des Cerfs' ;
const DEMONSTRATIONS = LIVREES . map ( ( { cle , texte } ) => ( {
cle ,
nom : CATALOGUE . find ( ( entree ) => entree . cle === cle ) . nom ,
texte ,
} ) ) ;
const UTF8 = new TextEncoder ( ) ;
// --- Le montage -------------------------------------------------------------
// Identifiants d'épreuve, numérotés dans l'ordre des tirages.
function creerIdentifiants ( prefixe ) {
let n = 0 ;
return ( ) => {
n += 1 ;
return ` ${ prefixe } - ${ n } ` ;
} ;
}
// La façade sur le système fs, branchée comme App la branche : changerRacine
// crée la séance sur la racine reçue. appels relève, dans l'ordre, ce que la
// façade demande à la séance, et ce que l'épreuve y ajoute.
function monter ( { fs = creerFichiersSimules ( ) , horloge = creerHorlogePilotee ( { depart : DEPART } ) , demonstrations = DEMONSTRATIONS } = { } ) {
const identifiant = creerIdentifiants ( 'id' ) ;
const appels = [ ] ;
let seance = null ;
let racine = null ;
const facade = creerFacade ( {
fs ,
horloge ,
identifiant ,
produitVersion : V1 ,
demonstrations ,
ouvert : ( ) => seance ? . ouvert ? . base ? ? null ,
ouvrir : async ( base ) => {
appels . push ( [ 'ouvrir' , base ] ) ;
return seance . ouvrir ( base ) ;
} ,
fermer : async ( ) => {
appels . push ( [ 'fermer' ] ) ;
return seance . fermer ( ) ;
} ,
creer : async ( champs ) => {
appels . push ( [ 'creer' , champs ] ) ;
return seance . creer ( champs ) ;
} ,
racine : ( ) => racine ,
changerRacine : async ( nouvelle ) => {
appels . push ( [ 'changerRacine' , nouvelle ] ) ;
racine = nouvelle ;
seance = creerSeance ( {
fs ,
racine : nouvelle ,
horloge ,
identifiant ,
produitVersion : V1 ,
planifier : horloge . planifier ,
annuler : horloge . annuler ,
} ) ;
} ,
} ) ;
return {
fs ,
horloge ,
facade ,
appels ,
get seance ( ) {
return seance ;
} ,
} ;
}
// Un événement posé par le dépôt, hors de la façade : sa paire, journal
// d'une entrée et son jalon, puis l'état.
async function deposerEvenement ( fs , racine , { id , nom , etat = null } ) {
const charge = creerCharge ( { id , nom , siegesParDefaut : 8 , tours : 2 } ) ;
if ( etat !== null ) charge . evenement . etat = etat ;
const ouvert = await creerDansLeDepot ( fs , racine , charge , {
libelle : libelle ( 'ENTREE_CREATION' , { nom } ) ,
nomJalon : libelle ( 'JALON_CREATION' ) ,
horodatage : '2031-05-17T08:00:00-04:00' ,
produitVersion : V1 ,
} ) ;
return ouvert . base ;
}
// Le texte d'état d'un événement neuf, que l'épreuve dépose tel quel.
const texteEtat = ( { id , nom } ) =>
serialiser ( creerCharge ( { id , nom , siegesParDefaut : 8 , tours : 2 } ) , { revision : 1 , produitVersion : V1 } ) ;
// Le refus qu'une promesse rejette.
async function refusDe ( promesse ) {
try {
await promesse ;
} catch ( erreur ) {
return erreur ;
}
assert . fail ( 'la promesse devait rejeter' ) ;
}
// Les textes de chaque fichier d'une racine, par nom, sous le dossier donné.
async function photographier ( fs , racine , dossier = '' ) {
const entrees = await fs . lister ( racine , dossier ) ;
const photo = { } ;
for ( const { nom , type } of entrees ) {
if ( type === 'fichier' ) photo [ nom ] = fs . contenu ( racine . id , dossier === '' ? nom : ` ${ dossier } / ${ nom } ` ) ;
}
return photo ;
}
const ecritures = ( fs ) => fs . compteurs . ecritures ;
// Le nom en NFD que copier et renommer reçoivent, et sa forme NFC, distinctes.
const NFD = 'Soire\u{0065}\u{0301}e des Hiboux' . normalize ( 'NFD' ) ;
const NFC = NFD . normalize ( 'NFC' ) ;
// Des Documents sous un chemin de 250 caractères : aucun nom long n'y tient
// sous la borne d'un chemin, que lève deriverBase.
const PROFOND = ` C: \\ ${ 'p' . repeat ( 247 ) } ` ;
// --- La détermination du dossier de travail (§ 8.6) --------------------------
describe ( 'demarrer : une détermination par séance (§ 8.6)' , ( ) => {
test ( 'le second demarrer ne lit ni n’ écrit rien, et rend le même résultat' , async ( ) => {
const { fs , facade , appels } = monter ( ) ;
const premier = await facade . demarrer ( ) ;
const { ecritures : e , lectures : l } = fs . compteurs ;
const second = await facade . demarrer ( ) ;
assert . equal ( second , premier ) ;
assert . equal ( fs . compteurs . ecritures , e ) ;
assert . equal ( fs . compteurs . lectures , l ) ;
assert . deepEqual ( premier . dossier . racine , DOC ) ;
assert . deepEqual ( appels , [ [ 'changerRacine' , DOC ] ] ) ;
} ) ;
test ( 'les cinq issues de S8 rendent leurs avis, dans l’ ordre d’ Avis' , async ( ) => {
const cas = [
[ { } , [ { code : 'NON_PUBLIE' , details : { } } ] ] ,
[ { executable : SOUS _APPDATA } , [ { code : 'DONNEES_APPLICATIVES' , details : { } } ] ] ,
[ { executable : PUBLIE , sonde : { portable : 'EACCES' } } , [ { code : 'SONDE_ECHOUEE' , details : { cause : 'EACCES' } } ] ] ,
[ { nature : 'web' } , [ { code : 'PLATEFORME_WEB' , details : { } } ] ] ,
[ { executable : PUBLIE , support : { portable : 'amovible' } } , [ { code : 'SUPPORT_AMOVIBLE' , details : { } } ] ] ,
[
{ nature : 'web' , support : { documents : 'amovible' } } ,
[
{ code : 'PLATEFORME_WEB' , details : { } } ,
{ code : 'SUPPORT_AMOVIBLE' , details : { } } ,
] ,
] ,
[ { executable : PUBLIE } , [ ] ] ,
] ;
assert . equal ( cas . length , 7 ) ;
for ( const [ systeme , attendus ] of cas ) {
const { facade } = monter ( { fs : creerFichiersSimules ( systeme ) } ) ;
const { avis } = await facade . demarrer ( ) ;
assert . deepEqual ( avis , attendus , JSON . stringify ( systeme ) ) ;
}
} ) ;
test ( 'changerRacine reçoit la racine retenue : data/ en mode portable' , async ( ) => {
const { facade , appels } = monter ( { fs : creerFichiersSimules ( { executable : PUBLIE } ) } ) ;
const { dossier } = await facade . demarrer ( ) ;
assert . equal ( dossier . mode , 'portable' ) ;
assert . deepEqual ( appels , [ [ 'changerRacine' , PORTABLE ] ] ) ;
assert . deepEqual ( facade . explorateur ( ) , { disponible : true , raison : null } ) ;
} ) ;
test ( 'des réglages locaux corrompus ajoutent REGLAGES_ILLISIBLES après les avis de S8' , async ( ) => {
const fs = creerFichiersSimules ( ) ;
fs . deposer ( 'documents' , 'reglages_locaux.json' , '{ pas du JSON' ) ;
const { facade } = monter ( { fs } ) ;
const { avis } = await facade . demarrer ( ) ;
assert . deepEqual ( avis , [
{ code : 'NON_PUBLIE' , details : { } } ,
{ code : 'REGLAGES_ILLISIBLES' , details : { chemin : 'reglages_locaux.json' , raison : 'JSON' } } ,
] ) ;
} ) ;
test ( 'une lecture refusée rejette demarrer sans rien retenir, et le suivant refait la détermination' , async ( ) => {
const fs = creerFichiersSimules ( ) ;
const lister = fs . lister ;
let pannes = 1 ;
fs . lister = async ( ... parametres ) => {
if ( pannes > 0 ) {
pannes -= 1 ;
throw new ErreurStockage ( 'LECTURE' , { chemin : '' , dossier : DOCUMENTS , cause : 'EIO' } ) ;
}
return lister ( ... parametres ) ;
} ;
const { facade , appels } = monter ( { fs } ) ;
const refus = await refusDe ( facade . demarrer ( ) ) ;
assert . equal ( refus . code , 'LECTURE' ) ;
assert . deepEqual ( appels , [ ] ) ;
const { dossier } = await facade . demarrer ( ) ;
assert . deepEqual ( dossier . racine , DOC ) ;
assert . deepEqual ( appels , [ [ 'changerRacine' , DOC ] ] ) ;
} ) ;
} ) ;
// --- Les questions (§ 8.6) ---------------------------------------------------
describe ( 'les questions du dossier de travail (§ 8.6)' , ( ) => {
// data/ porte deux événements, posés par un premier processus ; le second,
// monté sur le même disque, voit data/ refuser l'écriture.
async function dataQuiRefuse ( ) {
const disque = new Map ( ) ;
const avant = creerFichiersSimules ( { disque , executable : PUBLIE } ) ;
avant . deposerDossier ( 'portable' ) ;
await deposerEvenement ( avant , PORTABLE , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
await deposerEvenement ( avant , PORTABLE , { id : 'evt-cerfs' , nom : CERFS } ) ;
const fs = creerFichiersSimules ( { disque , executable : PUBLIE , sonde : { portable : 'EACCES' } } ) ;
const sondes = [ ] ;
const sonder = fs . sonder ;
fs . sonder = async ( racine ) => {
sondes . push ( racine . id ) ;
return sonder ( racine ) ;
} ;
return { fs , sondes } ;
}
test ( 'PORTABLE_NON_INSCRIPTIBLE : question au compte de 2, liste refusée tant qu’ elle attend' , async ( ) => {
const { fs } = await dataQuiRefuse ( ) ;
const { facade , appels } = monter ( { fs } ) ;
const { dossier , avis } = await facade . demarrer ( ) ;
assert . deepEqual ( dossier . question , {
code : 'PORTABLE_NON_INSCRIPTIBLE' ,
racine : PORTABLE ,
evenements : 2 ,
cause : 'EACCES' ,
} ) ;
assert . deepEqual ( avis , [ { code : 'SONDE_ECHOUEE' , details : { cause : 'EACCES' } } ] ) ;
assert . deepEqual ( appels , [ ] ) ;
const refus = await refusDe ( facade . lister ( ) ) ;
assert . equal ( refus . code , 'QUESTION_EN_ATTENTE' ) ;
assert . deepEqual ( refus . details , { question : 'PORTABLE_NON_INSCRIPTIBLE' , remede : null } ) ;
assert . equal ( typeof libelle ( refus . code , refus . details ) , 'string' ) ;
} ) ;
test ( '« reessayer » refait la détermination : deux sondes de plus, la même question' , async ( ) => {
const { fs , sondes } = await dataQuiRefuse ( ) ;
const { facade , appels } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
assert . deepEqual ( sondes , [ 'portable' , 'documents' ] ) ;
const avant = ecritures ( fs ) ;
const { dossier } = await facade . repondre ( 'reessayer' ) ;
assert . deepEqual ( sondes , [ 'portable' , 'documents' , 'portable' , 'documents' ] ) ;
// Les deux sondes, et rien d'autre.
assert . equal ( ecritures ( fs ) , avant + 2 ) ;
assert . equal ( dossier . question . code , 'PORTABLE_NON_INSCRIPTIBLE' ) ;
assert . deepEqual ( appels , [ ] ) ;
assert . equal ( ( await refusDe ( facade . lister ( ) ) ) . code , 'QUESTION_EN_ATTENTE' ) ;
} ) ;
test ( '« documents » copie les deux paires dans les Documents, originaux intacts' , async ( ) => {
const { fs } = await dataQuiRefuse ( ) ;
const { facade , appels } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
const originaux = await photographier ( fs , PORTABLE ) ;
assert . equal ( Object . keys ( originaux ) . length , 4 ) ;
const avant = ecritures ( fs ) ;
const { dossier } = await facade . repondre ( 'documents' ) ;
// Chaque paire : son journal, puis son état.
assert . equal ( ecritures ( fs ) , avant + 4 ) ;
assert . equal ( dossier . question , null ) ;
assert . equal ( dossier . autre , null ) ;
assert . deepEqual ( dossier . racine , DOC ) ;
assert . deepEqual ( appels , [ [ 'changerRacine' , DOC ] ] ) ;
assert . deepEqual ( await photographier ( fs , PORTABLE ) , originaux ) ;
const lignes = await facade . lister ( ) ;
assert . equal ( lignes . length , 2 ) ;
assert . deepEqual (
lignes . map ( ( { id , nom } ) => [ id , nom ] ) ,
[
[ 'evt-cerfs' , CERFS ] ,
[ 'evt-lucioles' , LUCIOLES ] ,
] ,
) ;
for ( const base of [ LUCIOLES , CERFS ] ) {
assert . equal ( fs . contenu ( 'documents' , base + SUFFIXES . etat ) , originaux [ base + SUFFIXES . etat ] ) ;
assert . equal ( fs . contenu ( 'documents' , base + SUFFIXES . journal ) , originaux [ base + SUFFIXES . journal ] ) ;
}
assert . equal ( ( await facade . demarrer ( ) ) . dossier , dossier ) ;
} ) ;
test ( 'un data/ qui ne se lit pas, compte null : « documents » n’ a rien à copier, ni à relire' , async ( ) => {
const { fs } = await dataQuiRefuse ( ) ;
const lister = fs . lister ;
const listes = [ ] ;
fs . lister = async ( racine , dossier ) => {
listes . push ( racine . id ) ;
if ( racine . id === 'portable' ) throw new ErreurStockage ( 'LECTURE' , { chemin : dossier , dossier : PORTABLE . chemin , cause : 'EIO' } ) ;
return lister ( racine , dossier ) ;
} ;
const { facade , appels } = monter ( { fs } ) ;
const { dossier } = await facade . demarrer ( ) ;
assert . deepEqual ( dossier . question , {
code : 'PORTABLE_NON_INSCRIPTIBLE' ,
racine : PORTABLE ,
evenements : null ,
cause : 'EACCES' ,
} ) ;
const lus = listes . length ;
const avant = ecritures ( fs ) ;
const reponse = await facade . repondre ( 'documents' ) ;
assert . deepEqual ( reponse . importe , { importes : [ ] , refuses : [ ] } ) ;
assert . deepEqual ( reponse . dossier . racine , DOC ) ;
assert . deepEqual ( appels , [ [ 'changerRacine' , DOC ] ] ) ;
assert . deepEqual ( listes . slice ( lus ) , [ ] ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
test ( '« continuer » ne répond pas à PORTABLE_NON_INSCRIPTIBLE : RangeError' , async ( ) => {
const { fs } = await dataQuiRefuse ( ) ;
const { facade , appels } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
await assert . rejects ( facade . repondre ( 'continuer' ) , RangeError ) ;
await assert . rejects ( facade . repondre ( 'ignorer' ) , RangeError ) ;
assert . deepEqual ( appels , [ ] ) ;
} ) ;
test ( 'DOCUMENTS_NON_INSCRIPTIBLE nomme le dossier, la cause et le compte ; « continuer » ouvre la liste sans écrire' , async ( ) => {
const disque = new Map ( ) ;
const avant = creerFichiersSimules ( { disque } ) ;
avant . deposerDossier ( 'documents' ) ;
await deposerEvenement ( avant , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
const fs = creerFichiersSimules ( { disque , sonde : { documents : 'EACCES' } } ) ;
const { facade , appels } = monter ( { fs } ) ;
const { dossier } = await facade . demarrer ( ) ;
assert . deepEqual ( dossier . question , {
code : 'DOCUMENTS_NON_INSCRIPTIBLE' ,
racine : DOC ,
evenements : 1 ,
cause : 'EACCES' ,
} ) ;
assert . equal ( ( await refusDe ( facade . lister ( ) ) ) . code , 'QUESTION_EN_ATTENTE' ) ;
await assert . rejects ( facade . repondre ( 'documents' ) , RangeError ) ;
assert . deepEqual ( appels , [ ] ) ;
const avant2 = ecritures ( fs ) ;
const reponse = await facade . repondre ( 'continuer' ) ;
assert . equal ( reponse . dossier . question , null ) ;
assert . deepEqual ( appels , [ [ 'changerRacine' , DOC ] ] ) ;
const lignes = await facade . lister ( ) ;
assert . deepEqual (
lignes . map ( ( { base } ) => base ) ,
[ LUCIOLES ] ,
) ;
assert . equal ( ecritures ( fs ) , avant2 ) ;
} ) ;
} ) ;
// --- La liste (§ 8.1, § 19.4) -----------------------------------------------
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
// Les lignes de la liste, chacune complétée des deux résumés que resumer
// rend pour elle, le flux mené à son terme sans attendre d'horloge.
async function listerResume ( facade ) {
const lignes = await facade . lister ( ) ;
const resumes = new Map ( ) ;
await facade . resumer ( lignes , { ceder : async ( ) => undefined , recevoir : ( base , resume ) => resumes . set ( base , resume ) } ) . fin ;
assert . equal ( resumes . size , lignes . length ) ;
return lignes . map ( ( ligne ) => ( { ... ligne , ... resumes . get ( ligne . base ) } ) ) ;
}
[ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.
--- FR ---
[ADD] application : séance observable, façade des événements, places
Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.
Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00
describe ( 'lister (§ 8.1, § 19.4)' , ( ) => {
// Publié en E:\soirees, data/ inscriptible : la petite démonstration
// déposée sans journal, et un événement renommé à la main.
async function deuxEvenements ( ) {
const fs = creerFichiersSimules ( { executable : PUBLIE } ) ;
const montage = monter ( { fs } ) ;
await montage . facade . demarrer ( ) ;
fs . deposer ( 'portable' , ` petite ${ SUFFIXES . etat } ` , texteLivre ( 'petite' ) ) ;
const base = await deposerEvenement ( fs , PORTABLE , { id : 'evt-gala' , nom : 'Gala du printemps' } ) ;
await fs . deplacer ( PORTABLE , base + SUFFIXES . etat , ` soiree-a ${ SUFFIXES . etat } ` ) ;
await fs . deplacer ( PORTABLE , base + SUFFIXES . journal , ` soiree-a ${ SUFFIXES . journal } ` ) ;
return montage ;
}
test ( 'six informations par ligne, le nom et le compte lus dans le fichier, rien d’ écrit' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
const avant = ecritures ( fs ) ;
const lignes = await facade . lister ( ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
const petite = analyser ( texteLivre ( 'petite' ) ) . charge ;
assert . deepEqual ( lignes , [
{
base : 'soiree-a' ,
id : 'evt-gala' ,
nom : 'Gala du printemps' ,
date : null ,
etat : 'brouillon' ,
participants : 0 ,
derniereModification : '2031-05-17T08:00:00-04:00' ,
chemin : ` E: \\ soirees \\ data \\ soiree-a ${ SUFFIXES . etat } ` ,
illisible : null ,
journalSeul : false ,
doublonAvec : [ ] ,
verrou : null ,
} ,
{
base : 'petite' ,
id : petite . evenement . id ,
nom : petite . evenement . nom ,
date : petite . evenement . date ,
etat : petite . evenement . etat ,
participants : petite . participants . length ,
derniereModification : null ,
chemin : ` E: \\ soirees \\ data \\ petite ${ SUFFIXES . etat } ` ,
illisible : null ,
journalSeul : false ,
doublonAvec : [ ] ,
verrou : null ,
} ,
] ) ;
assert . ok ( petite . participants . length > 0 ) ;
} ) ;
test ( 'deux exécutions rendent la même liste ; la date des fichiers n’ y change rien' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
const avant = ecritures ( fs ) ;
const premiere = await facade . lister ( ) ;
assert . equal ( premiere . length , 2 ) ;
assert . deepEqual ( await facade . lister ( ) , premiere ) ;
fs . deposer ( 'portable' , ` petite ${ SUFFIXES . etat } ` , texteLivre ( 'petite' ) , 1 ) ;
fs . deposer ( 'portable' , ` soiree-a ${ SUFFIXES . journal } ` , fs . contenu ( 'portable' , ` soiree-a ${ SUFFIXES . journal } ` ) , 2 ) ;
assert . deepEqual ( await facade . lister ( ) , premiere ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
test ( 'la dernière modification est l’ horodatage de la dernière entrée du journal' , async ( ) => {
const { fs , horloge , facade } = monter ( ) ;
await facade . demarrer ( ) ;
await facade . creer ( { nom : LUCIOLES } ) ;
horloge . avancer ( 3_600_000 ) ;
await facade . renommer ( LUCIOLES , { nom : CERFS } ) ;
const journal = lireJournal ( fs . contenu ( 'documents' , CERFS + SUFFIXES . journal ) ) ;
assert . deepEqual (
journal . entrees . map ( ( { horodatage } ) => horodatage ) ,
[ DEPART , '2026-10-06T20:00:00-04:00' ] ,
) ;
const avant = ecritures ( fs ) ;
const [ ligne ] = await facade . lister ( ) ;
assert . equal ( ligne . derniereModification , '2026-10-06T20:00:00-04:00' ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
test ( 'un .gtt.verrou présent se lit sans écrire ; illisible, sa séance et son instant sont null' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
const contenu = { seance : 'seance-voisine' , pid : 7 , hote : 'poste-voisin' , depuis : '2031-05-17T09:30:00-04:00' } ;
fs . deposer ( 'portable' , ` soiree-a ${ SUFFIXES . verrou } ` , ` ${ JSON . stringify ( contenu ) } \n ` ) ;
fs . deposer ( 'portable' , ` petite ${ SUFFIXES . verrou } ` , 'pas un verrou' ) ;
const avant = ecritures ( fs ) ;
const lignes = await facade . lister ( ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
assert . deepEqual (
lignes . map ( ( { base , verrou } ) => [ base , verrou ] ) ,
[
[ 'soiree-a' , { seance : 'seance-voisine' , depuis : '2031-05-17T09:30:00-04:00' } ] ,
[ 'petite' , { seance : null , depuis : null } ] ,
] ,
) ;
} ) ;
test ( 'signalements : état vide, journal seul, doublons ; rien pour les autres fichiers' , async ( ) => {
const fs = creerFichiersSimules ( ) ;
const { facade } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
fs . deposer ( 'documents' , ` Soirée vide ${ SUFFIXES . etat } ` , '' ) ;
const hiboux = await deposerEvenement ( fs , DOC , { id : 'evt-hiboux' , nom : 'Soirée des Hiboux' } ) ;
await fs . supprimer ( DOC , hiboux + SUFFIXES . etat ) ;
const merles = texteEtat ( { id : 'evt-merles' , nom : 'Club des Merles' } ) ;
fs . deposer ( 'documents' , ` Merles A ${ SUFFIXES . etat } ` , merles ) ;
fs . deposer ( 'documents' , ` Merles B ${ SUFFIXES . etat } ` , merles ) ;
fs . deposer ( 'documents' , 'reglages_locaux.json' , '{}' ) ;
fs . deposer ( 'documents' , ` corbeille/2031-01-01_00-00-00/Ancienne ${ SUFFIXES . etat } ` , merles ) ;
fs . deposer ( 'documents' , 'planche.pdf' , '%PDF' ) ;
const avant = ecritures ( fs ) ;
const lignes = await facade . lister ( ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
assert . equal ( lignes . length , 4 ) ;
const signalees = lignes . filter ( ( l ) => l . illisible !== null || l . journalSeul || l . doublonAvec . length > 0 ) ;
assert . equal ( signalees . length , 4 ) ;
const par = Object . fromEntries ( lignes . map ( ( ligne ) => [ ligne . base , ligne ] ) ) ;
assert . deepEqual ( Object . keys ( par ) . sort ( ) , [ 'Merles A' , 'Merles B' , 'Soirée des Hiboux' , 'Soirée vide' ] ) ;
assert . equal ( par [ 'Soirée vide' ] . illisible . raison , 'VIDE' ) ;
assert . deepEqual ( par [ 'Soirée vide' ] . illisible . secours , { precedent : null , journal : null } ) ;
assert . equal ( par [ 'Soirée vide' ] . journalSeul , false ) ;
assert . equal ( typeof libelle ( 'ETAT_ILLISIBLE' , par [ 'Soirée vide' ] . illisible ) , 'string' ) ;
assert . equal ( par [ 'Soirée des Hiboux' ] . journalSeul , true ) ;
assert . equal ( par [ 'Soirée des Hiboux' ] . id , 'evt-hiboux' ) ;
assert . deepEqual ( par [ 'Soirée des Hiboux' ] . illisible . secours , { precedent : null , journal : 1 } ) ;
assert . equal ( par [ 'Soirée des Hiboux' ] . derniereModification , '2031-05-17T08:00:00-04:00' ) ;
assert . deepEqual ( par [ 'Merles A' ] . doublonAvec , [ 'Merles B' ] ) ;
assert . deepEqual ( par [ 'Merles B' ] . doublonAvec , [ 'Merles A' ] ) ;
assert . equal ( par [ 'Merles A' ] . illisible , null ) ;
assert . equal ( par [ 'Soirée vide' ] . doublonAvec . length , 0 ) ;
} ) ;
[ADD] home: retained plan's collisions and redundancy gap per event
The event list showed no outcome, so comparing events meant opening each.
Each line now shows, for the retained plan only, the cumulated collisions
(pair and tour) and the redundancy gap |target - sum of r(p)|, with an
explanation tooltip; an event without a measurable retained plan shows a
dash. The target is set per event in Propositions, 1000 by default: an
optional settings field, absent in every existing file and read as 1000,
written through one undoable command refused in reading mode. Listing
stays read-only. Checked: 2909 node, 604 browser, 62 long tests.
--- FR ---
[ADD] accueil : collisions et Diff de redondance du retenu par événement
La liste des événements ne montrait aucun résultat : comparer obligeait à
ouvrir chacun. Chaque ligne montre, pour le retenu seul, les collisions
cumulées (paire et tour) et la Diff somme d'appartenance, |cible - somme
de r(p)|, avec une infobulle ; sans retenu mesurable, un tiret. La cible
se règle par événement dans Propositions, 1000 par défaut : champ
facultatif des réglages, absent de tout fichier existant et lu 1000, écrit
par une commande qui se défait et se refuse en lecture. La liste reste en
lecture seule. Vérifié : 2909 node, 604 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 05:35:48 -04:00
// La petite démonstration au conflit inévitable, deux propositions
// générées par le moteur et la seconde retenue, par les commandes ;
// modifier(charge) l'ajuste avant l'écriture.
function texteRetenu ( modifier = ( charge ) => charge ) {
let charge = analyser ( texteLivre ( 'petite-conflit' ) ) . charge ;
const configuration = configurationDepuisCharge ( charge ) ;
const propositions = rechercher ( configuration , { graine : 4 , nombre : 2 , arret : 300 } ) ;
charge = appliquerCommande ( 'enregistrerGeneration' , charge , { propositions , configuration , produitVersion : V1 } ) . charge ;
charge = appliquerCommande ( 'retenirProposition' , charge , { id : charge . propositions [ 1 ] . id } ) . charge ;
return serialiser ( modifier ( charge ) , { revision : 3 , produitVersion : V1 } ) ;
}
test ( 'les mesures du retenu : collisions cumulées, Σ r(p), la cible et la différence, celles de resumeDuRetenu ; null sans retenu ; rien d’ écrit' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
fs . deposer ( 'portable' , ` retenue ${ SUFFIXES . etat } ` , texteRetenu ( ) ) ;
const avant = ecritures ( fs ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
const lignes = await listerResume ( facade ) ;
[ADD] home: retained plan's collisions and redundancy gap per event
The event list showed no outcome, so comparing events meant opening each.
Each line now shows, for the retained plan only, the cumulated collisions
(pair and tour) and the redundancy gap |target - sum of r(p)|, with an
explanation tooltip; an event without a measurable retained plan shows a
dash. The target is set per event in Propositions, 1000 by default: an
optional settings field, absent in every existing file and read as 1000,
written through one undoable command refused in reading mode. Listing
stays read-only. Checked: 2909 node, 604 browser, 62 long tests.
--- FR ---
[ADD] accueil : collisions et Diff de redondance du retenu par événement
La liste des événements ne montrait aucun résultat : comparer obligeait à
ouvrir chacun. Chaque ligne montre, pour le retenu seul, les collisions
cumulées (paire et tour) et la Diff somme d'appartenance, |cible - somme
de r(p)|, avec une infobulle ; sans retenu mesurable, un tiret. La cible
se règle par événement dans Propositions, 1000 par défaut : champ
facultatif des réglages, absent de tout fichier existant et lu 1000, écrit
par une commande qui se défait et se refuse en lecture. La liste reste en
lecture seule. Vérifié : 2909 node, 604 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 05:35:48 -04:00
assert . equal ( ecritures ( fs ) , avant ) ;
const par = Object . fromEntries ( lignes . map ( ( ligne ) => [ ligne . base , ligne ] ) ) ;
const charge = analyser ( texteRetenu ( ) ) . charge ;
const { mesures } = evaluer ( charge ) . retenu ;
assert . deepEqual ( par . retenue . mesuresDuRetenu , {
collisionsCumulees : mesures . collisionsCumulees ,
totalRedondance : mesures . totalRedondance ,
cibleRedondance : 1000 ,
diffRedondance : Math . abs ( 1000 - mesures . totalRedondance ) ,
} ) ;
assert . deepEqual ( par . retenue . mesuresDuRetenu , resumeDuRetenu ( charge ) ) ;
assert . equal ( par [ 'soiree-a' ] . mesuresDuRetenu , null ) ;
assert . equal ( par . petite . mesuresDuRetenu , null ) ;
} ) ;
test ( "une cible propre à l'événement : la différence se lit contre elle" , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
const texte = texteRetenu ( ( charge ) => appliquerCommande ( 'changerCibleRedondance' , charge , { cible : '25' } ) . charge ) ;
fs . deposer ( 'portable' , ` retenue ${ SUFFIXES . etat } ` , texte ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
const ligne = ( await listerResume ( facade ) ) . find ( ( { base } ) => base === 'retenue' ) ;
[ADD] home: retained plan's collisions and redundancy gap per event
The event list showed no outcome, so comparing events meant opening each.
Each line now shows, for the retained plan only, the cumulated collisions
(pair and tour) and the redundancy gap |target - sum of r(p)|, with an
explanation tooltip; an event without a measurable retained plan shows a
dash. The target is set per event in Propositions, 1000 by default: an
optional settings field, absent in every existing file and read as 1000,
written through one undoable command refused in reading mode. Listing
stays read-only. Checked: 2909 node, 604 browser, 62 long tests.
--- FR ---
[ADD] accueil : collisions et Diff de redondance du retenu par événement
La liste des événements ne montrait aucun résultat : comparer obligeait à
ouvrir chacun. Chaque ligne montre, pour le retenu seul, les collisions
cumulées (paire et tour) et la Diff somme d'appartenance, |cible - somme
de r(p)|, avec une infobulle ; sans retenu mesurable, un tiret. La cible
se règle par événement dans Propositions, 1000 par défaut : champ
facultatif des réglages, absent de tout fichier existant et lu 1000, écrit
par une commande qui se défait et se refuse en lecture. La liste reste en
lecture seule. Vérifié : 2909 node, 604 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 05:35:48 -04:00
const { totalRedondance } = evaluer ( analyser ( texte ) . charge ) . retenu . mesures ;
assert . equal ( ligne . mesuresDuRetenu . cibleRedondance , 25 ) ;
assert . equal ( ligne . mesuresDuRetenu . diffRedondance , Math . abs ( 25 - totalRedondance ) ) ;
} ) ;
test ( 'un retenu fautif, ou en dérive, et un état illisible : null, la liste entière rendue, rien d’ écrit' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
fs . deposer (
'portable' ,
` fautive ${ SUFFIXES . etat } ` ,
texteRetenu ( ( charge ) => {
charge . retenu . placement [ 0 ] . reserve . push ( charge . retenu . participants [ 0 ] ) ;
return charge ;
} ) ,
) ;
fs . deposer ( 'portable' , ` derivee ${ SUFFIXES . etat } ` , texteRetenu ( ( charge ) => appliquerCommande ( 'changerTours' , charge , { tours : '3' } ) . charge ) ) ;
fs . deposer ( 'portable' , ` tronquee ${ SUFFIXES . etat } ` , texteRetenu ( ) . slice ( 0 , 40 ) ) ;
const avant = ecritures ( fs ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
const lignes = await listerResume ( facade ) ;
[ADD] home: retained plan's collisions and redundancy gap per event
The event list showed no outcome, so comparing events meant opening each.
Each line now shows, for the retained plan only, the cumulated collisions
(pair and tour) and the redundancy gap |target - sum of r(p)|, with an
explanation tooltip; an event without a measurable retained plan shows a
dash. The target is set per event in Propositions, 1000 by default: an
optional settings field, absent in every existing file and read as 1000,
written through one undoable command refused in reading mode. Listing
stays read-only. Checked: 2909 node, 604 browser, 62 long tests.
--- FR ---
[ADD] accueil : collisions et Diff de redondance du retenu par événement
La liste des événements ne montrait aucun résultat : comparer obligeait à
ouvrir chacun. Chaque ligne montre, pour le retenu seul, les collisions
cumulées (paire et tour) et la Diff somme d'appartenance, |cible - somme
de r(p)|, avec une infobulle ; sans retenu mesurable, un tiret. La cible
se règle par événement dans Propositions, 1000 par défaut : champ
facultatif des réglages, absent de tout fichier existant et lu 1000, écrit
par une commande qui se défait et se refuse en lecture. La liste reste en
lecture seule. Vérifié : 2909 node, 604 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 05:35:48 -04:00
assert . equal ( ecritures ( fs ) , avant ) ;
assert . equal ( lignes . length , 5 ) ;
const par = Object . fromEntries ( lignes . map ( ( ligne ) => [ ligne . base , ligne ] ) ) ;
assert . equal ( par . fautive . illisible , null ) ;
assert . equal ( par . fautive . mesuresDuRetenu , null ) ;
assert . equal ( par . derivee . mesuresDuRetenu , null ) ;
assert . notEqual ( par . tronquee . illisible , null ) ;
assert . equal ( par . tronquee . mesuresDuRetenu , null ) ;
} ) ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
test ( 'la meilleure proposition du classement : celle de resumeDuMeilleur, retenu ou non ; null sans proposition ; rien d’ écrit' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
// Les mêmes propositions, sans retenu, sous un autre identifiant : deux
// événements distincts, aucun doublon.
const sansRetenu = texteRetenu ( ( charge ) => ( {
... charge ,
retenu : null ,
evenement : { ... charge . evenement , id : 'evt-proposee' , etat : 'propose' } ,
} ) ) ;
fs . deposer ( 'portable' , ` retenue ${ SUFFIXES . etat } ` , texteRetenu ( ) ) ;
fs . deposer ( 'portable' , ` proposee ${ SUFFIXES . etat } ` , sansRetenu ) ;
const avant = ecritures ( fs ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
const lignes = await listerResume ( facade ) ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
assert . equal ( ecritures ( fs ) , avant ) ;
assert . equal ( lignes . length , 4 ) ;
const par = Object . fromEntries ( lignes . map ( ( ligne ) => [ ligne . base , ligne ] ) ) ;
for ( const [ base , texte ] of [
[ 'retenue' , texteRetenu ( ) ] ,
[ 'proposee' , sansRetenu ] ,
] ) {
const charge = analyser ( texte ) . charge ;
const [ premiere ] = evaluer ( charge ) . propositions ;
assert . equal ( premiere . statut , 'comparable' , base ) ;
assert . deepEqual ( par [ base ] . meilleureDuClassement , { id : premiere . id , collisionsCumulees : premiere . mesures . collisionsCumulees } , base ) ;
assert . deepEqual ( par [ base ] . meilleureDuClassement , resumeDuMeilleur ( charge ) , base ) ;
}
assert . equal ( par . proposee . mesuresDuRetenu , null ) ;
assert . equal ( par [ 'soiree-a' ] . meilleureDuClassement , null ) ;
assert . equal ( par . petite . meilleureDuClassement , null ) ;
} ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
test ( 'lister lit chaque état une fois, celle du dépôt ; resumer le relit une fois de plus pour ses deux résumés' , async ( ) => {
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
const { fs , facade } = await deuxEvenements ( ) ;
fs . deposer ( 'portable' , ` retenue ${ SUFFIXES . etat } ` , texteRetenu ( ) ) ;
const lireTexte = fs . lireTexte ;
const lus = [ ] ;
fs . lireTexte = ( racine , chemin ) => {
lus . push ( chemin ) ;
return lireTexte . call ( fs , racine , chemin ) ;
} ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
const lues = await facade . lister ( ) ;
assert . equal ( lues . length , 3 ) ;
const etats = ( ) => lus . filter ( ( chemin ) => chemin === ` retenue ${ SUFFIXES . etat } ` ) . length ;
assert . equal ( etats ( ) , 1 , lus . join ( ) ) ;
const ligne = ( await listerResume ( facade ) ) . find ( ( { base } ) => base === 'retenue' ) ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
assert . notEqual ( ligne . mesuresDuRetenu , null ) ;
assert . notEqual ( ligne . meilleureDuClassement , null ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
assert . equal ( etats ( ) , 3 , lus . join ( ) ) ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
} ) ;
// Un état que l'analyse admet et qu'examiner corrige (§ 8.9, point 3) :
// la ligne lit la charge que l'ouverture lit, examinée, et non la charge
// brute. Les propositions, sans retenu, sous modifier(charge).
const texteExamine = ( modifier ) => texteRetenu ( ( charge ) => modifier ( { ... charge , retenu : null } ) ) ;
const meilleureOuverte = ( texte ) => resumeDuMeilleur ( examiner ( analyser ( texte ) . charge ) . charge ) ;
test ( 'un identifiant répété : la ligne rendue, avec la meilleure proposition de la charge examinée' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
const texte = texteExamine ( ( charge ) => {
const [ premiere , seconde , ... reste ] = charge . propositions ;
return { ... charge , propositions : [ premiere , { ... seconde , id : premiere . id } , ... reste ] } ;
} ) ;
assert . equal ( examiner ( analyser ( texte ) . charge ) . fautives [ 0 ] . fautes [ 0 ] . code , 'IDENTIFIANT_REPETE' ) ;
fs . deposer ( 'portable' , ` repetee ${ SUFFIXES . etat } ` , texte ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
const ligne = ( await listerResume ( facade ) ) . find ( ( { base } ) => base === 'repetee' ) ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
assert . notEqual ( ligne , undefined ) ;
assert . notEqual ( meilleureOuverte ( texte ) , null ) ;
assert . deepEqual ( ligne . meilleureDuClassement , meilleureOuverte ( texte ) ) ;
} ) ;
test ( 'une proposition hors du compteur : la ligne nomme la meilleure de la charge examinée, celle du bloc de génération' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
// Le compteur abaissé à l'identifiant de la proposition au rang 1 de la
// charge brute : examiner l'écarte, et le rang 1 change.
const rangUn = resumeDuMeilleur ( analyser ( texteExamine ( ( charge ) => charge ) ) . charge ) ;
const texte = texteExamine ( ( charge ) => ( { ... charge , prochainsIds : { ... charge . prochainsIds , proposition : rangUn . id } } ) ) ;
assert . equal ( examiner ( analyser ( texte ) . charge ) . fautives [ 0 ] . fautes [ 0 ] . code , 'IDENTIFIANT_HORS_COMPTEUR' ) ;
assert . notDeepEqual ( meilleureOuverte ( texte ) , resumeDuMeilleur ( analyser ( texte ) . charge ) ) ;
fs . deposer ( 'portable' , ` abaissee ${ SUFFIXES . etat } ` , texte ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
const ligne = ( await listerResume ( facade ) ) . find ( ( { base } ) => base === 'abaissee' ) ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
assert . deepEqual ( ligne . meilleureDuClassement , meilleureOuverte ( texte ) ) ;
} ) ;
test ( 'des propositions toutes en dérive, et un état illisible : null, la liste entière rendue' , async ( ) => {
const { fs , facade } = await deuxEvenements ( ) ;
const derivee = texteRetenu ( ( charge ) => appliquerCommande ( 'changerTours' , charge , { tours : '3' } ) . charge ) ;
assert . ok ( evaluer ( analyser ( derivee ) . charge ) . propositions . every ( ( { statut } ) => statut === 'derive' ) ) ;
fs . deposer ( 'portable' , ` derivee ${ SUFFIXES . etat } ` , derivee ) ;
fs . deposer ( 'portable' , ` tronquee ${ SUFFIXES . etat } ` , texteRetenu ( ) . slice ( 0 , 40 ) ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
const lignes = await listerResume ( facade ) ;
[ADD] home: best ranked proposition on every event line
Each event line of the home lists now names its rank-1 proposition and
its cumulated collisions, whether or not a plan is retained, with the
same label and tooltip as the generation block. One application function
picks the rank-1 proposition for both places, so the list, the generation
block and the propositions table show the same proposition and number.
The list reads the examined charge, as opening the event does, so a file
with a repeated proposition identifier no longer hides the whole folder.
Checked: 2923 node, 609 browser, 62 long tests, from the index alone.
--- FR ---
[ADD] accueil : meilleure proposition classée sur chaque événement
Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses
collisions cumulées, retenu ou non, avec le même libellé et la même
infobulle que le bloc de la génération. Une seule fonction d'application
désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des
propositions montrent la même proposition et le même nombre. La liste lit
la charge examinée, comme l'ouverture : un fichier dont une proposition
répète un identifiant ne fait plus disparaître tout le dossier.
Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul.
Assisted-by: Claude Opus 5.5
2026-10-08 06:06:21 -04:00
assert . equal ( lignes . length , 4 ) ;
const par = Object . fromEntries ( lignes . map ( ( ligne ) => [ ligne . base , ligne ] ) ) ;
assert . equal ( par . derivee . meilleureDuClassement , null ) ;
assert . notEqual ( par . tronquee . illisible , null ) ;
assert . equal ( par . tronquee . meilleureDuClassement , null ) ;
} ) ;
[IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.
--- FR ---
[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement
La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.
Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00
// --- resumer : les résumés, un événement après l'autre ---------------------
// Un pas que tient l'horloge pilotée : sa promesse se résout à la prochaine
// avance de l'horloge, comme le pas de l'interface à sa minuterie.
const pasDe = ( horloge ) => ( ) =>
new Promise ( ( reprendre ) => {
horloge . planifier ( reprendre , 0 ) ;
} ) ;
// setImmediate rappelle une fois vidées les microtâches, où s'enchaînent
// les lectures du système simulé et le calcul qu'un pas libère.
const laisser = ( ) => new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
async function unPas ( horloge ) {
horloge . avancer ( 0 ) ;
await laisser ( ) ;
}
const AUCUN = { mesuresDuRetenu : null , meilleureDuClassement : null } ;
// Les deux résumés d'un texte d'état, sur sa charge examinée, celle que
// l'ouverture lit.
function resumesAttendus ( texte ) {
const charge = examiner ( analyser ( texte ) . charge ) . charge ;
return { mesuresDuRetenu : resumeDuRetenu ( charge ) , meilleureDuClassement : resumeDuMeilleur ( charge ) } ;
}
// Le dossier de deuxEvenements, plus un retenu, des propositions sans
// retenu et un état tronqué.
async function dossierAResumer ( ) {
const montage = await deuxEvenements ( ) ;
const sansRetenu = texteRetenu ( ( charge ) => ( {
... charge ,
retenu : null ,
evenement : { ... charge . evenement , id : 'evt-proposee' , etat : 'propose' } ,
} ) ) ;
montage . fs . deposer ( 'portable' , ` retenue ${ SUFFIXES . etat } ` , texteRetenu ( ) ) ;
montage . fs . deposer ( 'portable' , ` proposee ${ SUFFIXES . etat } ` , sansRetenu ) ;
montage . fs . deposer ( 'portable' , ` tronquee ${ SUFFIXES . etat } ` , texteRetenu ( ) . slice ( 0 , 40 ) ) ;
return { ... montage , textes : { retenue : texteRetenu ( ) , proposee : sansRetenu } } ;
}
test ( 'lister rend ses lignes sans résumé ; resumer rend l’ état illisible aussitôt, puis un événement par pas, dans l’ ordre de la liste, rien d’ écrit' , async ( ) => {
const { fs , horloge , facade , textes } = await dossierAResumer ( ) ;
const avant = ecritures ( fs ) ;
const lignes = await facade . lister ( ) ;
assert . equal ( lignes . length , 5 ) ;
for ( const ligne of lignes ) {
assert . equal ( 'mesuresDuRetenu' in ligne , false , ligne . base ) ;
assert . equal ( 'meilleureDuClassement' in ligne , false , ligne . base ) ;
}
const recus = [ ] ;
const flux = facade . resumer ( lignes , { ceder : pasDe ( horloge ) , recevoir : ( base , resume ) => recus . push ( [ base , resume ] ) } ) ;
// Un état illisible n'a rien à résumer : son résumé vide vient avant
// tout pas.
assert . deepEqual ( recus , [ [ 'tronquee' , AUCUN ] ] ) ;
await laisser ( ) ;
assert . equal ( recus . length , 1 ) ;
const lisibles = lignes . filter ( ( { illisible } ) => illisible === null ) . map ( ( { base } ) => base ) ;
assert . equal ( lisibles . length , 4 ) ;
for ( let pas = 1 ; pas <= lisibles . length ; pas += 1 ) {
await unPas ( horloge ) ;
assert . deepEqual (
recus . slice ( 1 ) . map ( ( [ base ] ) => base ) ,
lisibles . slice ( 0 , pas ) ,
) ;
}
await flux . fin ;
const par = new Map ( recus ) ;
assert . equal ( par . size , 5 ) ;
for ( const base of [ 'retenue' , 'proposee' ] ) assert . deepEqual ( par . get ( base ) , resumesAttendus ( textes [ base ] ) , base ) ;
assert . notEqual ( par . get ( 'retenue' ) . mesuresDuRetenu , null ) ;
assert . notEqual ( par . get ( 'proposee' ) . meilleureDuClassement , null ) ;
assert . deepEqual ( par . get ( 'petite' ) , AUCUN ) ;
assert . deepEqual ( par . get ( 'soiree-a' ) , AUCUN ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
test ( 'rien ne s’ évalue avant le pas : un état réécrit entre la liste et son pas se résume tel que le pas le lit' , async ( ) => {
const { fs , horloge , facade } = await dossierAResumer ( ) ;
const lignes = await facade . lister ( ) ;
const recus = new Map ( ) ;
const flux = facade . resumer ( lignes , { ceder : pasDe ( horloge ) , recevoir : ( base , resume ) => recus . set ( base , resume ) } ) ;
// La cible de l'événement passe à 25 après la liste : le résumé du pas
// la lit, et non celle que la liste a vue.
const plusTard = texteRetenu ( ( charge ) => appliquerCommande ( 'changerCibleRedondance' , charge , { cible : '25' } ) . charge ) ;
fs . deposer ( 'portable' , ` retenue ${ SUFFIXES . etat } ` , plusTard ) ;
for ( let pas = 0 ; pas < lignes . length ; pas += 1 ) await unPas ( horloge ) ;
await flux . fin ;
assert . equal ( recus . get ( 'retenue' ) . mesuresDuRetenu . cibleRedondance , 25 ) ;
assert . deepEqual ( recus . get ( 'retenue' ) , resumesAttendus ( plusTard ) ) ;
} ) ;
test ( 'abandonner : le calcul en cours ne se reçoit pas, et aucune lecture ne part ensuite' , async ( ) => {
const { fs , horloge , facade } = await dossierAResumer ( ) ;
const lignes = await facade . lister ( ) ;
const lisibles = lignes . filter ( ( { illisible } ) => illisible === null ) . map ( ( { base } ) => base ) ;
// La lecture du deuxième état lisible reste suspendue jusqu'à liberer().
const lireTexte = fs . lireTexte ;
let liberer ;
const barriere = new Promise ( ( resoudre ) => {
liberer = resoudre ;
} ) ;
let suspendue = null ;
fs . lireTexte = ( racine , chemin ) => {
if ( chemin === lisibles [ 1 ] + SUFFIXES . etat && suspendue === null ) {
suspendue = barriere . then ( ( ) => lireTexte . call ( fs , racine , chemin ) ) ;
return suspendue ;
}
return lireTexte . call ( fs , racine , chemin ) ;
} ;
const recus = [ ] ;
const flux = facade . resumer ( lignes , { ceder : pasDe ( horloge ) , recevoir : ( base ) => recus . push ( base ) } ) ;
await unPas ( horloge ) ;
await unPas ( horloge ) ;
assert . notEqual ( suspendue , null ) ;
assert . deepEqual ( recus , [ 'tronquee' , lisibles [ 0 ] ] ) ;
flux . abandonner ( ) ;
// La lecture suspendue s'achève et compte ; aucune ne part après elle.
liberer ( ) ;
await laisser ( ) ;
const lectures = fs . compteurs . lectures ;
for ( let pas = 0 ; pas < lignes . length ; pas += 1 ) await unPas ( horloge ) ;
await flux . fin ;
assert . deepEqual ( recus , [ 'tronquee' , lisibles [ 0 ] ] ) ;
assert . equal ( fs . compteurs . lectures , lectures ) ;
} ) ;
test ( 'sans dossier de travail, resumer lève RangeError sans rien recevoir' , ( ) => {
const { facade } = monter ( ) ;
const recus = [ ] ;
assert . throws (
( ) => facade . resumer ( [ { base : 'x' , illisible : { raison : 'VIDE' } } ] , { ceder : async ( ) => undefined , recevoir : ( base ) => recus . push ( base ) } ) ,
RangeError ,
) ;
assert . deepEqual ( recus , [ ] ) ;
} ) ;
test ( 'abandonné avant son premier pas, le flux ne lit rien et ne rend que les résumés vides des états illisibles' , async ( ) => {
const { fs , horloge , facade } = await dossierAResumer ( ) ;
const lignes = await facade . lister ( ) ;
const lectures = fs . compteurs . lectures ;
const recus = [ ] ;
const flux = facade . resumer ( lignes , { ceder : pasDe ( horloge ) , recevoir : ( base ) => recus . push ( base ) } ) ;
flux . abandonner ( ) ;
for ( let pas = 0 ; pas < lignes . length ; pas += 1 ) await unPas ( horloge ) ;
await flux . fin ;
assert . deepEqual ( recus , [ 'tronquee' ] ) ;
assert . equal ( fs . compteurs . lectures , lectures ) ;
} ) ;
test ( 'un état qui ne se relit plus rend son résumé vide, les autres le leur ; une faute de même, puis fin la rejette' , async ( ) => {
const { fs , horloge , facade , textes } = await dossierAResumer ( ) ;
const lignes = await facade . lister ( ) ;
// Tronqué après la liste : la relecture du pas le trouve illisible.
fs . deposer ( 'portable' , ` proposee ${ SUFFIXES . etat } ` , textes . proposee . slice ( 0 , 40 ) ) ;
const recus = new Map ( ) ;
const flux = facade . resumer ( lignes , { ceder : pasDe ( horloge ) , recevoir : ( base , resume ) => recus . set ( base , resume ) } ) ;
for ( let pas = 0 ; pas < lignes . length ; pas += 1 ) await unPas ( horloge ) ;
await flux . fin ;
assert . deepEqual ( recus . get ( 'proposee' ) , AUCUN ) ;
assert . deepEqual ( recus . get ( 'retenue' ) , resumesAttendus ( textes . retenue ) ) ;
// Une faute — une erreur qui n'est pas un refus du stockage — sur un
// seul état : son résumé vide, les suivants rendus, puis fin la rejette.
fs . deposer ( 'portable' , ` proposee ${ SUFFIXES . etat } ` , textes . proposee ) ;
const faute = new TypeError ( 'faute d’ épreuve' ) ;
const lireTexte = fs . lireTexte ;
fs . lireTexte = ( racine , chemin ) => ( chemin === ` petite ${ SUFFIXES . etat } ` ? Promise . reject ( faute ) : lireTexte . call ( fs , racine , chemin ) ) ;
const apres = new Map ( ) ;
const suivant = facade . resumer ( lignes , { ceder : pasDe ( horloge ) , recevoir : ( base , resume ) => apres . set ( base , resume ) } ) ;
const issue = suivant . fin . then (
( ) => null ,
( erreur ) => erreur ,
) ;
for ( let pas = 0 ; pas < lignes . length ; pas += 1 ) await unPas ( horloge ) ;
assert . equal ( await issue , faute ) ;
assert . equal ( apres . size , 5 ) ;
assert . deepEqual ( apres . get ( 'petite' ) , AUCUN ) ;
assert . deepEqual ( apres . get ( 'retenue' ) , resumesAttendus ( textes . retenue ) ) ;
assert . deepEqual ( apres . get ( 'proposee' ) , resumesAttendus ( textes . proposee ) ) ;
assert . notEqual ( apres . get ( 'proposee' ) . meilleureDuClassement , null ) ;
} ) ;
[ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.
--- FR ---
[ADD] application : séance observable, façade des événements, places
Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.
Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00
} ) ;
// --- Créer (§ 2.1, § 8.3) ----------------------------------------------------
describe ( 'creer (§ 2.1, § 8.3)' , ( ) => {
test ( '« Soirée d’ essai » : creer de la séance, une entrée, le jalon « Création », ouvert en lecture' , async ( ) => {
const montage = monter ( ) ;
const { fs , facade , appels } = montage ;
await facade . demarrer ( ) ;
const avant = ecritures ( fs ) ;
const { base } = await facade . creer ( { nom : "Soirée d'essai" , date : null } ) ;
assert . equal ( ecritures ( fs ) , avant + 2 ) ;
assert . deepEqual ( appels . at ( - 1 ) , [ 'creer' , { nom : "Soirée d'essai" , date : null , siegesParDefaut : 8 , tours : 4 } ] ) ;
const journal = lireJournal ( fs . contenu ( 'documents' , base + SUFFIXES . journal ) ) ;
assert . equal ( journal . entrees . length , 1 ) ;
assert . deepEqual (
journal . jalons . map ( ( { nom } ) => nom ) ,
[ 'Création' ] ,
) ;
assert . equal ( montage . seance . ouvert . base , base ) ;
assert . equal ( montage . seance . mode , 'lecture' ) ;
const { charge } = analyser ( fs . contenu ( 'documents' , base + SUFFIXES . etat ) ) ;
assert . equal ( charge . evenement . siegesParDefaut , 8 ) ;
assert . equal ( charge . evenement . tours , 4 ) ;
} ) ;
test ( 'une coupure après la première écriture laisse le journal sans l’ état : le journal d’ abord' , async ( ) => {
const disque = new Map ( ) ;
const fs = creerFichiersSimules ( { disque } ) ;
const { facade } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
fs . pannes . couperApres ( 1 ) ;
await assert . rejects ( facade . creer ( { nom : "Soirée d'essai" } ) ) ;
const relu = creerFichiersSimules ( { disque } ) ;
assert . notEqual ( relu . contenu ( 'documents' , ` Soirée d'essai ${ SUFFIXES . journal } ` ) , null ) ;
assert . equal ( relu . contenu ( 'documents' , ` Soirée d'essai ${ SUFFIXES . etat } ` ) , null ) ;
} ) ;
test ( 'un nom en NFD est inscrit en NFC' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
const nfd = 'Soire\u{0065}\u{0301}e des Lucioles' . normalize ( 'NFD' ) ;
assert . notEqual ( nfd , nfd . normalize ( 'NFC' ) ) ;
const { base } = await facade . creer ( { nom : nfd } ) ;
assert . equal ( analyser ( fs . contenu ( 'documents' , base + SUFFIXES . etat ) ) . charge . evenement . nom , nfd . normalize ( 'NFC' ) ) ;
} ) ;
test ( 'nom blanc, date hors du calendrier ou mal écrite : refus, rien d’ écrit' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
const avant = ecritures ( fs ) ;
assert . equal ( ( await refusDe ( facade . creer ( { nom : ' ' } ) ) ) . code , 'NOM_REQUIS' ) ;
for ( const date of [ '2026-02-30' , '2026-2-3' ] ) {
const refus = await refusDe ( facade . creer ( { nom : LUCIOLES , date } ) ) ;
assert . equal ( refus . code , 'DATE_INVALIDE' ) ;
assert . equal ( refus . details . date , date ) ;
}
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
test ( 'des Documents sous un chemin de 250 caractères : CHEMIN_TROP_LONG, rien d’ écrit' , async ( ) => {
assert . equal ( PROFOND . length , 250 ) ;
const { fs , facade } = monter ( { fs : creerFichiersSimules ( { documents : PROFOND } ) } ) ;
await facade . demarrer ( ) ;
const avant = ecritures ( fs ) ;
assert . equal ( ( await refusDe ( facade . creer ( { nom : LUCIOLES } ) ) ) . code , 'CHEMIN_TROP_LONG' ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
} ) ;
// --- Charger une démonstration (§ 15) ---------------------------------------
describe ( 'chargerDemonstration (§ 15)' , ( ) => {
test ( 'deux chargements : « ‹ nom› 1 » puis « ‹ nom› 2 », identités distinctes, ouverts en lecture' , async ( ) => {
const montage = monter ( ) ;
const { fs , facade } = montage ;
await facade . demarrer ( ) ;
const { base : courant } = await facade . creer ( { nom : LUCIOLES } ) ;
const courantAvant = await photographier ( fs , DOC ) ;
const courants = [ courant + SUFFIXES . etat , courant + SUFFIXES . journal ] ;
assert . deepEqual ( Object . keys ( courantAvant ) . sort ( ) , [ ... courants ] . sort ( ) ) ;
const livre = texteLivre ( 'petite' ) ;
const nom = CATALOGUE . find ( ( entree ) => entree . cle === 'petite' ) . nom ;
const avant = ecritures ( fs ) ;
const premier = await facade . chargerDemonstration ( 'petite' ) ;
// Le journal, puis l'état.
assert . equal ( ecritures ( fs ) , avant + 2 ) ;
assert . equal ( montage . seance . ouvert . base , premier . base ) ;
assert . equal ( montage . seance . mode , 'lecture' ) ;
const second = await facade . chargerDemonstration ( 'petite' ) ;
assert . equal ( ecritures ( fs ) , avant + 4 ) ;
assert . equal ( montage . seance . ouvert . base , second . base ) ;
assert . equal ( montage . seance . mode , 'lecture' ) ;
const lire = ( base ) => analyser ( fs . contenu ( 'documents' , base + SUFFIXES . etat ) ) . charge . evenement ;
assert . equal ( lire ( premier . base ) . nom , ` ${ nom } 1 ` ) ;
assert . equal ( lire ( second . base ) . nom , ` ${ nom } 2 ` ) ;
assert . notEqual ( lire ( premier . base ) . id , lire ( second . base ) . id ) ;
const journal = lireJournal ( fs . contenu ( 'documents' , premier . base + SUFFIXES . journal ) ) ;
assert . deepEqual (
journal . entrees . map ( ( { libelle : texte } ) => texte ) ,
[ ` Chargement de la démonstration « ${ nom } » ` ] ,
) ;
assert . equal ( texteLivre ( 'petite' ) , livre ) ;
const photo = await photographier ( fs , DOC ) ;
for ( const fichier of courants ) {
assert . equal ( typeof courantAvant [ fichier ] , 'string' ) ;
assert . equal ( photo [ fichier ] , courantAvant [ fichier ] ) ;
}
} ) ;
test ( 'une clé inconnue lève RangeError, sans rien écrire' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
const avant = ecritures ( fs ) ;
await assert . rejects ( facade . chargerDemonstration ( 'inconnue' ) , RangeError ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
} ) ;
// --- Copier (§ 8.7) ----------------------------------------------------------
describe ( 'copier (§ 8.7)' , ( ) => {
test ( 'depuis un plan bloqué : nouvel identifiant, une entrée, non bloquée, original intact' , async ( ) => {
const montage = monter ( ) ;
const { fs , facade } = montage ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES , etat : 'bloque' } ) ;
const original = await photographier ( fs , DOC ) ;
assert . equal ( Object . keys ( original ) . length , 2 ) ;
const avant = ecritures ( fs ) ;
const { base } = await facade . copier ( LUCIOLES , { nom : ' Variante des Lucioles ' } ) ;
// Le journal de la copie, puis son état.
assert . equal ( ecritures ( fs ) , avant + 2 ) ;
assert . equal ( base , 'Variante des Lucioles' ) ;
assert . equal ( montage . seance . ouvert . base , base ) ;
assert . equal ( montage . seance . mode , 'lecture' ) ;
const copie = analyser ( fs . contenu ( 'documents' , base + SUFFIXES . etat ) ) . charge . evenement ;
assert . notEqual ( copie . id , 'evt-lucioles' ) ;
assert . notEqual ( copie . etat , 'bloque' ) ;
assert . equal ( copie . nom , 'Variante des Lucioles' ) ;
const creation = libelle ( 'ENTREE_CREATION' , { nom : LUCIOLES } ) ;
const journal = lireJournal ( fs . contenu ( 'documents' , base + SUFFIXES . journal ) ) ;
assert . deepEqual (
journal . entrees . map ( ( { libelle : texte } ) => texte ) ,
[ ` Créée par copie de « ${ LUCIOLES } », à l'instant « ${ creation } » ` ] ,
) ;
// L'entrée nomme l'instant que la filiation inscrit.
assert . equal ( copie . filiation . instant . libelle , creation ) ;
const apres = await photographier ( fs , DOC ) ;
for ( const [ fichier , texte ] of Object . entries ( original ) ) assert . equal ( apres [ fichier ] , texte ) ;
} ) ;
test ( 'un nom vide est refusé, sans écrire, et aucun « (copie) » ne paraît' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
const avant = ecritures ( fs ) ;
for ( const nom of [ '' , ' \t ' ] ) {
const refus = await refusDe ( facade . copier ( LUCIOLES , { nom } ) ) ;
assert . equal ( refus . code , 'NOM_REQUIS' ) ;
assert . equal ( refus . details . remede , null ) ;
}
assert . equal ( ecritures ( fs ) , avant ) ;
const lignes = await facade . lister ( ) ;
assert . equal ( lignes . length , 1 ) ;
assert . ok ( lignes . every ( ( { nom } ) => ! nom . includes ( '(copie)' ) ) ) ;
} ) ;
test ( 'un nom en NFD : la copie porte le nom et la base en NFC' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
assert . notEqual ( NFD , NFC ) ;
const { base } = await facade . copier ( LUCIOLES , { nom : NFD } ) ;
assert . equal ( base , NFC ) ;
assert . equal ( analyser ( fs . contenu ( 'documents' , NFC + SUFFIXES . etat ) ) . charge . evenement . nom , NFC ) ;
assert . equal ( fs . contenu ( 'documents' , NFD + SUFFIXES . etat ) , null ) ;
} ) ;
test ( 'des Documents trop profonds pour le nom : CHEMIN_TROP_LONG, rien d’ écrit' , async ( ) => {
const { fs , facade } = monter ( { fs : creerFichiersSimules ( { documents : PROFOND } ) } ) ;
await facade . demarrer ( ) ;
fs . deposer ( 'documents' , ` Court ${ SUFFIXES . etat } ` , texteEtat ( { id : 'evt-court' , nom : 'Court' } ) ) ;
const avant = ecritures ( fs ) ;
const refus = await refusDe ( facade . copier ( 'Court' , { nom : 'x' . repeat ( 120 ) } ) ) ;
assert . equal ( refus . code , 'CHEMIN_TROP_LONG' ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
assert . deepEqual ( Object . keys ( await photographier ( fs , { id : 'documents' , chemin : PROFOND } ) ) , [ ` Court ${ SUFFIXES . etat } ` ] ) ;
} ) ;
} ) ;
// --- Renommer (§ 8.7, § 8.8) -------------------------------------------------
describe ( 'renommer (§ 8.7, § 8.8)' , ( ) => {
test ( 'nouveau nom, nouveau chemin, une entrée du renommage, aucun verrou restant' , async ( ) => {
const { fs , facade , appels } = monter ( ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
const avant = ecritures ( fs ) ;
const { base } = await facade . renommer ( LUCIOLES , { nom : CERFS } ) ;
// La sonde qui prend le verrou ; la nouvelle paire — .precedent, journal,
// état — ; l'effacement de l'ancienne — état, .precedent, journal — ; le
// verrou rendu.
assert . equal ( ecritures ( fs ) , avant + 1 + 3 + 3 + 1 ) ;
assert . equal ( base , CERFS ) ;
const lignes = await facade . lister ( ) ;
assert . deepEqual (
lignes . map ( ( { base : b , nom , chemin } ) => [ b , nom , chemin ] ) ,
[ [ CERFS , CERFS , ` ${ DOCUMENTS } \\ ${ CERFS } ${ SUFFIXES . etat } ` ] ] ,
) ;
assert . equal ( fs . contenu ( 'documents' , LUCIOLES + SUFFIXES . verrou ) , null ) ;
assert . equal ( fs . contenu ( 'documents' , CERFS + SUFFIXES . verrou ) , null ) ;
const journal = lireJournal ( fs . contenu ( 'documents' , CERFS + SUFFIXES . journal ) ) ;
assert . equal ( journal . entrees . at ( - 1 ) . libelle , ` Renommé de « ${ LUCIOLES } » en « ${ CERFS } » ` ) ;
assert . deepEqual (
appels . filter ( ( [ nom ] ) => nom !== 'changerRacine' ) ,
[ ] ,
) ;
} ) ;
// Coupe le processus dès que l'écriture atomique a posé ce fichier.
function couperApresEcriture ( fs , chemin ) {
const ecrire = fs . ecrireAtomique ;
fs . ecrireAtomique = async ( racine , cible , texte ) => {
await ecrire ( racine , cible , texte ) ;
if ( cible === chemin ) fs . pannes . couperApres ( 0 ) ;
} ;
}
test ( 'une coupure après le nouvel état laisse deux paires, signalées doublons l’ une de l’ autre' , async ( ) => {
const disque = new Map ( ) ;
const fs = creerFichiersSimules ( { disque } ) ;
const { facade } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
couperApresEcriture ( fs , CERFS + SUFFIXES . etat ) ;
await assert . rejects ( facade . renommer ( LUCIOLES , { nom : CERFS } ) ) ;
const relu = monter ( { fs : creerFichiersSimules ( { disque } ) } ) ;
await relu . facade . demarrer ( ) ;
const lignes = await relu . facade . lister ( ) ;
assert . deepEqual (
lignes . map ( ( { base , doublonAvec } ) => [ base , doublonAvec ] ) ,
[
[ CERFS , [ LUCIOLES ] ] ,
[ LUCIOLES , [ CERFS ] ] ,
] ,
) ;
} ) ;
test ( 'une coupure après le seul nouveau journal laisse une ligne, sous l’ ancienne base' , async ( ) => {
const disque = new Map ( ) ;
const fs = creerFichiersSimules ( { disque } ) ;
const { facade } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
couperApresEcriture ( fs , CERFS + SUFFIXES . journal ) ;
await assert . rejects ( facade . renommer ( LUCIOLES , { nom : CERFS } ) ) ;
const relu = monter ( { fs : creerFichiersSimules ( { disque } ) } ) ;
await relu . facade . demarrer ( ) ;
const lignes = await relu . facade . lister ( ) ;
assert . deepEqual (
lignes . map ( ( { base , nom } ) => [ base , nom ] ) ,
[ [ LUCIOLES , LUCIOLES ] ] ,
) ;
} ) ;
test ( 'plan bloqué : LECTURE_SEULE {raison: BLOQUE}, remède « débloquer », rien d’ écrit' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES , etat : 'bloque' } ) ;
const avant = ecritures ( fs ) ;
const refus = await refusDe ( facade . renommer ( LUCIOLES , { nom : CERFS } ) ) ;
assert . equal ( refus . code , 'LECTURE_SEULE' ) ;
assert . deepEqual ( refus . details , { raison : 'BLOQUE' , remede : { geste : 'debloquer' } } ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
test ( 'un nom vide : NOM_REQUIS avant toute sonde, rien d’ écrit' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
const avant = ecritures ( fs ) ;
assert . equal ( ( await refusDe ( facade . renommer ( LUCIOLES , { nom : ' ' } ) ) ) . code , 'NOM_REQUIS' ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
test ( 'un nom en NFD : le nom et la base renommés sont en NFC' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
assert . notEqual ( NFD , NFC ) ;
const { base } = await facade . renommer ( LUCIOLES , { nom : NFD } ) ;
assert . equal ( base , NFC ) ;
assert . equal ( analyser ( fs . contenu ( 'documents' , NFC + SUFFIXES . etat ) ) . charge . evenement . nom , NFC ) ;
assert . equal ( fs . contenu ( 'documents' , NFD + SUFFIXES . etat ) , null ) ;
} ) ;
test ( 'CHEMIN_TROP_LONG, levé sous le verrou : le verrou se rend, le disque reste tel qu’ il était' , async ( ) => {
const { fs , facade } = monter ( { fs : creerFichiersSimules ( { documents : PROFOND } ) } ) ;
await facade . demarrer ( ) ;
const profond = { id : 'documents' , chemin : PROFOND } ;
fs . deposer ( 'documents' , ` Court ${ SUFFIXES . etat } ` , texteEtat ( { id : 'evt-court' , nom : 'Court' } ) ) ;
const photo = await photographier ( fs , profond ) ;
assert . deepEqual ( Object . keys ( photo ) , [ ` Court ${ SUFFIXES . etat } ` ] ) ;
const avant = ecritures ( fs ) ;
const refus = await refusDe ( facade . renommer ( 'Court' , { nom : 'x' . repeat ( 120 ) } ) ) ;
assert . equal ( refus . code , 'CHEMIN_TROP_LONG' ) ;
// La sonde qui prend le verrou, puis le verrou rendu.
assert . equal ( ecritures ( fs ) , avant + 2 ) ;
assert . equal ( fs . contenu ( 'documents' , ` Court ${ SUFFIXES . verrou } ` ) , null ) ;
assert . deepEqual ( await photographier ( fs , profond ) , photo ) ;
} ) ;
test ( 'l’ événement ouvert en écriture : fermé d’ abord, aucun VERROU_PRIS, rouvert sous la nouvelle base' , async ( ) => {
const montage = monter ( ) ;
const { facade , appels } = montage ;
await facade . demarrer ( ) ;
await facade . creer ( { nom : LUCIOLES } ) ;
await montage . seance . passerEnEcriture ( ) ;
assert . equal ( montage . seance . mode , 'ecriture' ) ;
const debut = appels . length ;
const { base } = await facade . renommer ( LUCIOLES , { nom : CERFS } ) ;
assert . deepEqual ( appels . slice ( debut ) , [ [ 'fermer' ] , [ 'ouvrir' , CERFS ] ] ) ;
assert . equal ( base , CERFS ) ;
assert . equal ( montage . seance . ouvert . base , CERFS ) ;
assert . equal ( montage . seance . mode , 'lecture' ) ;
assert . equal ( montage . fs . contenu ( 'documents' , LUCIOLES + SUFFIXES . verrou ) , null ) ;
} ) ;
test ( 'le verrou d’ une séance vivante : VERROU_PRIS, rien ne bouge ; celui d’ une séance morte s’ efface' , async ( ) => {
const disque = new Map ( ) ;
const fs = creerFichiersSimules ( { disque } ) ;
const { facade } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
const voisin = creerFichiersSimules ( { disque } ) ;
await voisin . verrouiller ( DOC , LUCIOLES + SUFFIXES . verrou , 'seance-voisine' ) ;
const avant = await photographier ( fs , DOC ) ;
assert . equal ( Object . keys ( avant ) . length , 3 ) ;
const ecrituresAvant = ecritures ( fs ) ;
const refus = await refusDe ( facade . renommer ( LUCIOLES , { nom : CERFS } ) ) ;
assert . equal ( refus . code , 'VERROU_PRIS' ) ;
assert . equal ( refus . details . seance , 'seance-voisine' ) ;
assert . equal ( refus . details . vivant , true ) ;
// La seule sonde, refusée.
assert . equal ( ecritures ( fs ) , ecrituresAvant + 1 ) ;
assert . deepEqual ( await photographier ( fs , DOC ) , avant ) ;
voisin . pannes . couperApres ( 0 ) ;
const avantReprise = ecritures ( fs ) ;
const { base } = await facade . renommer ( LUCIOLES , { nom : CERFS } ) ;
// La sonde refusée, le verrou mort effacé, la sonde qui le prend ; puis
// le renommage : nouvelle paire, ancienne effacée, verrou rendu.
assert . equal ( ecritures ( fs ) , avantReprise + 3 + 3 + 3 + 1 ) ;
assert . equal ( base , CERFS ) ;
assert . equal ( fs . contenu ( 'documents' , LUCIOLES + SUFFIXES . verrou ) , null ) ;
} ) ;
} ) ;
// --- Supprimer (§ 8.7, § 8.8) ------------------------------------------------
describe ( 'supprimer (§ 8.7, § 8.8)' , ( ) => {
test ( 'les fichiers partent dans la corbeille datée par l’ horloge, l’ ouvert fermé d’ abord' , async ( ) => {
const { fs , facade , appels } = monter ( ) ;
await facade . demarrer ( ) ;
await facade . creer ( { nom : LUCIOLES } ) ;
const deplacer = fs . deplacer ;
fs . deplacer = async ( ... parametres ) => {
appels . push ( [ 'deplacer' , parametres [ 1 ] ] ) ;
return deplacer ( ... parametres ) ;
} ;
const avant = ecritures ( fs ) ;
const { dossier } = await facade . supprimer ( LUCIOLES ) ;
// La sonde qui prend le verrou, le dossier daté, puis trois déplacements :
// le verrou, le journal, l'état.
assert . equal ( ecritures ( fs ) , avant + 1 + 1 + 3 ) ;
assert . equal ( dossier , CORBEILLE _DU _DEPART ) ;
const fermer = appels . findIndex ( ( [ nom ] ) => nom === 'fermer' ) ;
const premierDeplacement = appels . findIndex ( ( [ nom ] ) => nom === 'deplacer' ) ;
assert . ok ( fermer >= 0 && premierDeplacement > fermer ) ;
assert . deepEqual ( await facade . lister ( ) , [ ] ) ;
assert . deepEqual ( await facade . corbeille ( ) , [
{
dossier : CORBEILLE _DU _DEPART ,
chemin : ` ${ DOCUMENTS } \\ corbeille \\ 2026-10-06_19-00-00 ` ,
fichiers : [ LUCIOLES + SUFFIXES . journal , LUCIOLES + SUFFIXES . etat , LUCIOLES + SUFFIXES . verrou ] ,
} ,
] ) ;
} ) ;
test ( 'le verrou d’ une séance morte ne retient rien : il part avec l’ événement' , async ( ) => {
const disque = new Map ( ) ;
const fs = creerFichiersSimules ( { disque } ) ;
const { facade } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
const mort = creerFichiersSimules ( { disque } ) ;
await mort . verrouiller ( DOC , LUCIOLES + SUFFIXES . verrou , 'seance-morte' ) ;
mort . pannes . couperApres ( 0 ) ;
const avant = ecritures ( fs ) ;
await facade . supprimer ( LUCIOLES ) ;
// La sonde, que le verrou mort n'arrête pas, le dossier daté, trois
// déplacements.
assert . equal ( ecritures ( fs ) , avant + 1 + 1 + 3 ) ;
const [ { fichiers } ] = await facade . corbeille ( ) ;
assert . ok ( fichiers . includes ( LUCIOLES + SUFFIXES . verrou ) ) ;
assert . deepEqual ( await facade . lister ( ) , [ ] ) ;
} ) ;
test ( 'un plan bloqué se supprime : rien ne se détruit, ses fichiers passent dans la corbeille' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES , etat : 'bloque' } ) ;
const original = await photographier ( fs , DOC ) ;
assert . equal ( Object . keys ( original ) . length , 2 ) ;
const avant = ecritures ( fs ) ;
const { dossier } = await facade . supprimer ( LUCIOLES ) ;
assert . equal ( ecritures ( fs ) , avant + 1 + 1 + 3 ) ;
assert . equal ( dossier , CORBEILLE _DU _DEPART ) ;
assert . deepEqual ( await facade . lister ( ) , [ ] ) ;
const [ { fichiers } ] = await facade . corbeille ( ) ;
assert . deepEqual ( fichiers , [ LUCIOLES + SUFFIXES . journal , LUCIOLES + SUFFIXES . etat , LUCIOLES + SUFFIXES . verrou ] ) ;
for ( const [ fichier , texte ] of Object . entries ( original ) ) {
assert . equal ( fs . contenu ( 'documents' , ` ${ CORBEILLE _DU _DEPART } / ${ fichier } ` ) , texte ) ;
}
} ) ;
test ( 'le verrou d’ une séance vivante, puis d’ un autre hôte : VERROU_PRIS, fichiers en place' , async ( ) => {
const cas = [
[ { } , true ] ,
[ { hote : 'poste-voisin' } , null ] ,
] ;
assert . equal ( cas . length , 2 ) ;
for ( const [ reglages , vivant ] of cas ) {
const disque = new Map ( ) ;
const fs = creerFichiersSimules ( { disque } ) ;
const { facade } = monter ( { fs } ) ;
await facade . demarrer ( ) ;
await deposerEvenement ( fs , DOC , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
const autre = creerFichiersSimules ( { disque , ... reglages } ) ;
await autre . verrouiller ( DOC , LUCIOLES + SUFFIXES . verrou , 'seance-autre' ) ;
const avant = await photographier ( fs , DOC ) ;
assert . equal ( Object . keys ( avant ) . length , 3 ) ;
const ecrituresAvant = ecritures ( fs ) ;
const refus = await refusDe ( facade . supprimer ( LUCIOLES ) ) ;
assert . equal ( refus . code , 'VERROU_PRIS' ) ;
assert . equal ( refus . details . vivant , vivant ) ;
// La seule sonde, refusée.
assert . equal ( ecritures ( fs ) , ecrituresAvant + 1 ) ;
assert . deepEqual ( await photographier ( fs , DOC ) , avant ) ;
assert . deepEqual ( await facade . corbeille ( ) , [ ] ) ;
}
} ) ;
} ) ;
// --- Importer (§ 8.6, § 8.7) -------------------------------------------------
describe ( 'importer (§ 8.6, § 8.7)' , ( ) => {
const MERLES = { id : 'evt-merles' , nom : 'Club des Merles' } ;
test ( 'un fichier choisi : ses octets sous une base dérivée de son nom ; une seconde fois, IDENTIFIANT_PRESENT' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
const octets = UTF8 . encode ( texteEtat ( MERLES ) ) ;
fs . choisirFichierAImporter = async ( ) => ( { nom : 'recu-par-courriel.gtt.json' , octets } ) ;
const avant = ecritures ( fs ) ;
const { base } = await facade . importerFichier ( ) ;
// L'état seul, aucun verrou.
assert . equal ( ecritures ( fs ) , avant + 1 ) ;
assert . equal ( base , MERLES . nom ) ;
assert . deepEqual ( fs . octets ( 'documents' , base + SUFFIXES . etat ) , octets ) ;
const photo = await photographier ( fs , DOC ) ;
assert . equal ( Object . keys ( photo ) . length , 1 ) ;
const avantRefus = ecritures ( fs ) ;
const refus = await refusDe ( facade . importerFichier ( ) ) ;
assert . equal ( refus . code , 'IDENTIFIANT_PRESENT' ) ;
assert . deepEqual ( refus . details , { id : MERLES . id , base : MERLES . nom } ) ;
assert . equal ( ecritures ( fs ) , avantRefus ) ;
assert . deepEqual ( await photographier ( fs , DOC ) , photo ) ;
} ) ;
test ( 'un fichier d’ un format plus récent s’ importe tel quel' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
const texte = texteEtat ( MERLES ) . replace ( '"format":1' , '"format":2' ) ;
assert . ok ( texte . includes ( '"format":2' ) ) ;
const octets = UTF8 . encode ( texte ) ;
fs . choisirFichierAImporter = async ( ) => ( { nom : 'futur.gtt.json' , octets } ) ;
const avant = ecritures ( fs ) ;
const { base } = await facade . importerFichier ( ) ;
assert . equal ( ecritures ( fs ) , avant + 1 ) ;
assert . deepEqual ( fs . octets ( 'documents' , base + SUFFIXES . etat ) , octets ) ;
} ) ;
test ( 'le dialogue annulé rend null, sans rien écrire' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
const avant = ecritures ( fs ) ;
assert . equal ( await facade . importerFichier ( ) , null ) ;
assert . equal ( ecritures ( fs ) , avant ) ;
} ) ;
test ( 'l’ autre emplacement : Documents neufs, data/ en porte deux → copiés, originaux intacts' , async ( ) => {
const disque = new Map ( ) ;
const avant = creerFichiersSimules ( { disque , executable : SOUS _APPDATA } ) ;
avant . deposerDossier ( 'portable' ) ;
await deposerEvenement ( avant , PORTABLE _APPDATA , { id : 'evt-lucioles' , nom : LUCIOLES } ) ;
await deposerEvenement ( avant , PORTABLE _APPDATA , { id : 'evt-cerfs' , nom : CERFS } ) ;
const fs = creerFichiersSimules ( { disque , executable : SOUS _APPDATA } ) ;
const { facade } = monter ( { fs } ) ;
const { dossier } = await facade . demarrer ( ) ;
assert . deepEqual ( dossier . autre , { racine : PORTABLE _APPDATA , evenements : 2 } ) ;
const originaux = await photographier ( fs , PORTABLE _APPDATA ) ;
assert . equal ( Object . keys ( originaux ) . length , 4 ) ;
const ecrituresAvant = ecritures ( fs ) ;
const { importes , refuses } = await facade . importerDepuisAutre ( ) ;
// Chaque paire : son journal, puis son état.
assert . equal ( ecritures ( fs ) , ecrituresAvant + 4 ) ;
assert . equal ( importes . length , 2 ) ;
assert . deepEqual ( refuses , [ ] ) ;
assert . deepEqual ( await photographier ( fs , PORTABLE _APPDATA ) , originaux ) ;
assert . deepEqual (
( await facade . lister ( ) ) . map ( ( { id } ) => id ) ,
[ 'evt-cerfs' , 'evt-lucioles' ] ,
) ;
assert . equal ( ( await facade . demarrer ( ) ) . dossier . autre , null ) ;
} ) ;
} ) ;
// --- Les dialogues de fichiers, relayés (contrat de données) -----------------
describe ( 'les dialogues de fichiers, lus sur fs à l’ appel' , ( ) => {
test ( 'choisirCsv rend tels quels {nom, octets} et null' , async ( ) => {
const { fs , facade } = monter ( ) ;
assert . equal ( await facade . choisirCsv ( ) , null ) ;
const choisi = { nom : 'liste.csv' , octets : UTF8 . encode ( 'nom\nOmbrelle\n' ) } ;
fs . choisirFichierAImporter = async ( ) => choisi ;
assert . equal ( await facade . choisirCsv ( ) , choisi ) ;
} ) ;
test ( 'enregistrerSous remet nom et octets tels quels, et rend le chemin ou null' , async ( ) => {
const { fs , facade } = monter ( ) ;
assert . equal ( await facade . enregistrerSous ( 'a.csv' , new Uint8Array ( [ 1 ] ) ) , null ) ;
const recus = [ ] ;
fs . enregistrerSous = async ( nom , octets ) => {
recus . push ( [ nom , octets ] ) ;
return 'C:\\Users\\Exemple\\Documents\\liste-refus.csv' ;
} ;
const octets = UTF8 . encode ( 'motif\n' ) ;
assert . equal ( await facade . enregistrerSous ( 'liste-refus.csv' , octets ) , 'C:\\Users\\Exemple\\Documents\\liste-refus.csv' ) ;
assert . equal ( recus . length , 1 ) ;
assert . equal ( recus [ 0 ] [ 0 ] , 'liste-refus.csv' ) ;
assert . equal ( recus [ 0 ] [ 1 ] , octets ) ;
} ) ;
} ) ;
// --- L'explorateur (§ 8.6) ---------------------------------------------------
describe ( 'explorateur (§ 8.6)' , ( ) => {
test ( 'sous web : indisponible, EXPLORATEUR_ABSENT, aucun appel' , async ( ) => {
const { fs , facade } = monter ( { fs : creerFichiersSimules ( { nature : 'web' } ) } ) ;
await facade . demarrer ( ) ;
const recus = [ ] ;
fs . ouvrirDansExplorateur = async ( racine ) => recus . push ( racine ) ;
assert . deepEqual ( facade . explorateur ( ) , { disponible : false , raison : 'EXPLORATEUR_ABSENT' } ) ;
assert . equal ( typeof libelle ( 'EXPLORATEUR_ABSENT' ) , 'string' ) ;
const refus = await refusDe ( facade . ouvrirExplorateur ( ) ) ;
assert . equal ( refus . code , 'EXPLORATEUR_ABSENT' ) ;
assert . deepEqual ( recus , [ ] ) ;
} ) ;
test ( 'ailleurs : ouvrirDansExplorateur reçoit la racine' , async ( ) => {
const { fs , facade } = monter ( ) ;
await facade . demarrer ( ) ;
const recus = [ ] ;
fs . ouvrirDansExplorateur = async ( racine ) => recus . push ( racine ) ;
assert . deepEqual ( facade . explorateur ( ) , { disponible : true , raison : null } ) ;
await facade . ouvrirExplorateur ( ) ;
assert . deepEqual ( recus , [ DOC ] ) ;
} ) ;
} ) ;