Compare commits

...

2 commits

Author SHA1 Message Date
99524aa9bf [ADD] storage: web file system over the origin private file system
Under the web platform, used to develop and test on Linux, events live
in the browser's origin private file system: one root, with no
executable folder, no atomic rename and no lock, which it declares so
the start-up banner can say so (§ 8.8). Imports come through a file
input and exports through a download link. The same contract suite as
the test double runs on it in Chromium, its exclusivity check skipped
because there is no lock to test.

Checked: the contract suite in the browser project; one review round.

--- FR ---

[ADD] stockage : système de fichiers web, sur le stockage de l'origine

Sous la plateforme web, qui sert au développement et aux épreuves sous
Linux, les événements vivent dans le stockage privé de l'origine du
navigateur : une seule racine, sans dossier d'exécutable, sans renommage
atomique ni verrou, ce qu'il déclare pour que le bandeau de démarrage le
dise (§ 8.8). Les imports passent par un champ de fichier, les exports
par un lien de téléchargement. La suite de contrat du double s'y joue
dans Chromium, sauf l'exclusivité, faute de verrou à éprouver.

Vérifié : la suite de contrat sous le projet navigateur ; une revue.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:03:45 -04:00
2bca543e7e [ADD] demo: delivered event files, example CSVs, payload fingerprint
The four demonstrations ship as event files written once by a script,
the source of truth of § 15.5: a test replays the generator and compares
SHA-256 fingerprints of the payload alone, naming the paths that differ,
so a new build version never forces a regeneration. The affiliation
profile is read from the delivered file. The two demonstration CSVs are
the export of those files; the edge-case CSV is written by hand, and
its windows-1252 variant derived. Version and forged-name guards skip
these generated files, as § 18.5 and § 15.6 allow.

Checked: node and long series; the script writes only its seven files.

--- FR ---

[ADD] démo : fichiers d'événement livrés, CSV d'exemple, empreinte

Les quatre démonstrations se livrent en fichiers d'événement qu'un
script écrit une fois, source de vérité du § 15.5 : une épreuve rejoue
le générateur et compare les empreintes SHA-256 de la seule charge, en
nommant les chemins qui diffèrent ; une nouvelle version n'impose donc
aucune régénération. Le profil d'appartenances se lit dans le fichier
livré. Les deux CSV de démonstration sont l'export de ces fichiers ;
celui des cas limites s'écrit à la main, sa variante windows-1252 en
dérive. Les gardes de version et de noms forgés passent ces fichiers.

Vérifié : séries node et longue ; le script n'écrit que ses sept fichiers.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:03:15 -04:00
20 changed files with 3432 additions and 19 deletions

2
.gitattributes vendored
View file

@ -1,3 +1,5 @@
* text=auto eol=lf
*.cmd text eol=crlf
test/fixtures/csv/** -text
src/demo/livrees/*.gtt.json -text
exemples/*.csv -text

View file

@ -0,0 +1,16 @@
Nom;Prénom;Organisation;Courriel;Rôle;Exclu;Notes;Téléphone
Lanterneau;Basile;Chœur de l’Anse;basile.lanterneau@exemple.test;animation;;;555-0101
Pommerel;Capucine;Société des Hérons;capucine.pommerel@exemple.test;;;;555-0102
Marbrelle;Gaspard;Cercle des Saules;;animation;;;555-0103
Sablonnet;Ysaline;Chœur de l’Anse;;;;;555-0104
Givrecœur;Anselme;;anselme.givrecoeur@exemple.test;;;;555-0105
Tourterel;Héloïse;Cercle des Saules;;;oui;S’est désistée la veille;555-0106
Fenouillet;Côme;Société des Hérons;;;;"végétarien; sans noix, ni arachides";555-0107
Coudraie;Apolline;Chœur de l’Anse;;;;"Arrive après le premier tour
repart avant le dessert";555-0108
Lanterneau;Basile;Chœur de l’Anse;;;;;555-0109
Brunœil;;Cercle des Saules;brunoeil@exemple.test;;;;555-0110
Ormelin;Faustine;Societe des herons;;;;;555-0111
;Clotilde;Cercle des Saules;clotilde@exemple.test;;;;555-0112
Ardoisier;Edgar;Société des Hérons;;;;;555-0113
Cressonnier;Bertille;Cercle des Saules;;;;;555-0114
1 Nom Prénom Organisation Courriel Rôle Exclu Notes Téléphone
2 Lanterneau Basile Chœur de l’Anse basile.lanterneau@exemple.test animation 555-0101
3 Pommerel Capucine Société des Hérons capucine.pommerel@exemple.test 555-0102
4 Marbrelle Gaspard Cercle des Saules animation 555-0103
5 Sablonnet Ysaline Chœur de l’Anse 555-0104
6 Givrecœur Anselme anselme.givrecoeur@exemple.test 555-0105
7 Tourterel Héloïse Cercle des Saules oui S’est désistée la veille 555-0106
8 Fenouillet Côme Société des Hérons végétarien; sans noix, ni arachides 555-0107
9 Coudraie Apolline Chœur de l’Anse Arrive après le premier tour repart avant le dessert 555-0108
10 Lanterneau Basile Chœur de l’Anse 555-0109
11 Brunœil Cercle des Saules brunoeil@exemple.test 555-0110
12 Ormelin Faustine Societe des herons 555-0111
13 Clotilde Cercle des Saules clotilde@exemple.test 555-0112
14 Ardoisier Edgar Société des Hérons 555-0113
15 Cressonnier Bertille Cercle des Saules 555-0114

View file

@ -0,0 +1,16 @@
Nom,Prénom,Organisation,Courriel,Rôle,Exclu,Notes,Téléphone
Lanterneau,Basile,Chœur de l’Anse,basile.lanterneau@exemple.test,animation,,,555-0101
Pommerel,Capucine,Société des Hérons,capucine.pommerel@exemple.test,,,,555-0102
Marbrelle,Gaspard,Cercle des Saules,,animation,,,555-0103
Sablonnet,Ysaline,Chœur de l’Anse,,,,,555-0104
Givrecœur,Anselme,,anselme.givrecoeur@exemple.test,,,,555-0105
Tourterel,Héloïse,Cercle des Saules,,,oui,S’est désistée la veille,555-0106
Fenouillet,Côme,Société des Hérons,,,,"végétarien; sans noix, ni arachides",555-0107
Coudraie,Apolline,Chœur de l’Anse,,,,"Arrive après le premier tour
repart avant le dessert",555-0108
Lanterneau,Basile,Chœur de l’Anse,,,,,555-0109
Brunœil,,Cercle des Saules,brunoeil@exemple.test,,,,555-0110
Ormelin,Faustine,Societe des herons,,,,,555-0111
,Clotilde,Cercle des Saules,clotilde@exemple.test,,,,555-0112
Ardoisier,Edgar,Société des Hérons,,,,,555-0113
Cressonnier,Bertille,Cercle des Saules,,,,,555-0114
1 Nom Prénom Organisation Courriel Rôle Exclu Notes Téléphone
2 Lanterneau Basile Chœur de l’Anse basile.lanterneau@exemple.test animation 555-0101
3 Pommerel Capucine Société des Hérons capucine.pommerel@exemple.test 555-0102
4 Marbrelle Gaspard Cercle des Saules animation 555-0103
5 Sablonnet Ysaline Chœur de l’Anse 555-0104
6 Givrecœur Anselme anselme.givrecoeur@exemple.test 555-0105
7 Tourterel Héloïse Cercle des Saules oui S’est désistée la veille 555-0106
8 Fenouillet Côme Société des Hérons végétarien; sans noix, ni arachides 555-0107
9 Coudraie Apolline Chœur de l’Anse Arrive après le premier tour repart avant le dessert 555-0108
10 Lanterneau Basile Chœur de l’Anse 555-0109
11 Brunœil Cercle des Saules brunoeil@exemple.test 555-0110
12 Ormelin Faustine Societe des herons 555-0111
13 Clotilde Cercle des Saules clotilde@exemple.test 555-0112
14 Ardoisier Edgar Société des Hérons 555-0113
15 Cressonnier Bertille Cercle des Saules 555-0114

View file

@ -0,0 +1,261 @@
nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes
Mirvault;Isabelle;Comptoir Gravelune;;;non;
Neskière;Youssef;Verrerie Vaubrenne;;animation;non;
Abrevande;Félix;Imprimerie Yzorne;;;non;
Fralvançon;Hugo;Brasserie Wassorel;;;non;
Fralveux;Manon;Menuiserie Estrolle;;;non;
Luskotte;Catherine;Fonderie Estrolle;;;non;
WAMMERWOLM-MAWWEMMEL;Zoé;Menuiserie Ulvardes;;;non;
Neskichon;Louise;Ateliers Rossolde;;animation;non;
Urvesson;Catherine;Fabrique Ulvardes;;;non;
Hulvivet;Karim;Fonderie Xandrelle;;;non;
Selveron;Caroline;Ateliers Cassorgue;;;non;
Korvault;Catherine;Tannerie Nervanche;;;non;
Fralvotte;Caroline;Fonderie Ivoranche;;;non;
Jusvesson;Geneviève;Tannerie Belvarre;;;non;
Abrevumet;Paul;Ateliers Aubrenc;;;non;
Fralvault;Léa;Fonderie Sorbagne;;;non;
Obrivet;Fatima;Fabrique Sorbagne;;;non;
Voskeval;Philippe;Menuiserie Estrolle;;animation;non;
Dulveval;Zoé;Menuiserie Quimvaux;;;non;
Korveux;Paul;Filature Olbrecq;;;non;
Luskière;Julie;Fabrique Sorbagne;;;non;
Clovande;Émilie;Ateliers Falvigne;;;non;
Hulvande;Paul;Verrerie Vaubrenne;;;non;
Fralvesson;Hugo;Filature Wassorel;;;non;
Urvière;Isabelle;Brasserie Belvarre;;;non;
Rozvotte;Thomas;Verrerie Ivoranche;;;non;
Bralmumet;Isabelle;Verrerie Aubrenc;;;non;
Dulvouze;Catherine;Ateliers Rossolde;;;non;
Voskichon;Marc;Menuiserie Estrolle;;;non;
Mirvivet;Simon;Ateliers Ivoranche;;;non;
Clovault;Louise;Imprimerie Yzorne;;;non;
Voskeux;Lucie;Coopérative Morvalle;;;non;
Eskignac;Amina;Brasserie Xandrelle;;;non;
Korvouze;Fatima;Ateliers Tavrenne;;animation;non;
Selvoubre;Luc;Brasserie Sorbagne;;;non;
Fralvichon;Étienne;Papeterie Ulvardes;;;non;
Voskivet;Philippe;Ateliers Tavrenne;;;non;
Quilmançon;Louise;Menuiserie Estrolle;;;non;
Luskande;Sophie;Fabrique Morvalle;;;non;
Abreveron;Pierre;Ateliers Rossolde;;;non;
Neskeux;Philippe;Comptoir Xandrelle;;;non;
Neskançon;Amina;Fabrique Ulvardes;;;non;
Tolvande;Léa;Ateliers Rossolde;;;non;
Quilmeron;Antoine;Fabrique Kalvenne;;;non;
Clovumet;Guillaume;Verrerie Pravette;;;non;
Eskeux;Michel;Papeterie Wassorel;;;non;
Hulvault;Louise;Coopérative Jolvane;;;non;
Hulveron;Catherine;Coopérative Rossolde;;animation;non;
Ivrivet;François;Menuiserie Sorbagne;;animation;non;
Eskumet;Samuel;Verrerie Ivoranche;;;non;
Fralvivet;Claire;Imprimerie Yzorne;;;non;
Mirveux;Léa;Menuiserie Wassorel;;;non;
Gorzesson;Karim;Papeterie Ulvardes;;;non;
Fralvumet;Jean;Ateliers Falvigne;;animation;non;
Urvançon;Manon;Ateliers Tavrenne;;animation;non;
Quilmande;Sylvie;Imprimerie Yzorne;;;non;
Gorzichon;François;Ateliers Estrolle;;;non;
Pruvesson;Amina;Fonderie Sorbagne;;;non;
Quilmière;Lucie;Fabrique Ivoranche;;;non;
Korvesson;Fatima;Ateliers Rossolde;;;non;
Selvesson;Nicolas;Imprimerie Yzorne;;;non;
Selvivet;Louis;Menuiserie Hostrenne;;animation;non;
Neskesson;Louise;Tannerie Belvarre;;;non;
Tolvesson;Lucie;Filature Zelvaine;;;non;
Quilmesson;Olivier;Comptoir Gravelune;;;non;
Luskivet;Guillaume;Brasserie Lavorneau;;;non;
Neskault;Gabriel;Menuiserie Tavrenne;;;non;
Urvumet;Antoine;Menuiserie Hostrenne;;;non;
Jusvançon;Olivier;Brasserie Morvalle;;;non;
Dulvançon;Simon;Menuiserie Ivoranche;;;non;
Gorzard;Chloé;Fabrique Sorbagne;;;non;
Pruveux;Gabriel;Papeterie Estrolle;;;non;
Clovière;Manon;Ateliers Ivoranche;;;non;
Obrouze;François;Tannerie Olbrecq;;animation;non;
Tolveval;Karim;Tannerie Belvarre;;;non;
Dulvumet;Geneviève;Menuiserie Quimvaux;;animation;non;
Bérêçàuvïlle-Dœûmôntëÿ;Hugo;Ateliers Falvigne;;;non;
Gorzeron;Claire;Menuiserie Hostrenne;;;non;
Luskumet;Hélène;Brasserie Xandrelle;;;non;
Fralvande;Louis;Ateliers Rossolde;;;non;
Selvault;Émilie;Fabrique Sorbagne;;;non;
Neskumet;Léa;Verrerie Aubrenc;;;non;
Korvichon;Isabelle;Menuiserie Ulvardes;;;non;
Obrande;Michel;Menuiserie Estrolle;;;non;
Luskeval;Isabelle;Brasserie Wassorel;;;non;
Tolvoubre;Fatima;Papeterie Ulvardes;;;non;
Gorzière;Zoé;Menuiserie Olbrecq;;;non;
Korvignac;Sylvie;Fabrique Ulvardes;;;non;
Obreval;Antoine;Ateliers Cassorgue;;;non;
Korveron;Michel;Fonderie Sorbagne;;animation;non;
Tolvumet;Camille;Fonderie Estrolle;;;non;
Dulvesson;Marie;Ateliers Cassorgue;;;non;
Jusvard;Youssef;Tannerie Aubrenc;;animation;non;
Tolvignac;Guillaume;Menuiserie Sorbagne;;;non;
Mirvotte;Gabriel;Tannerie Belvarre;;;non;
Urvard;Marie;Filature Wassorel;;animation;non;
Bralmesson;Marie;Coopérative Dravenne;;animation;non;
Neskignac;Paul;Brasserie Sorbagne;;;non;
Bralmeux;Samuel;Ateliers Rossolde;;animation;non;
Ivrotte;Raphaël;Coopérative Rossolde;;;non;
Luskouze;Marc;Ateliers Rossolde;;;non;
Jusvotte;Sylvie;Brasserie Belvarre;;;non;
Bralmoubre;Véronique;Fonderie Xandrelle;;;non;
Selvard;Pierre;Verrerie Vaubrenne;;;non;
Hulveux;Marie;Coopérative Rossolde;;;non;
Pruvotte;Sophie;Imprimerie Yzorne;;animation;non;
Fralveval;Karim;Coopérative Gravelune;;;non;
Gorzignac;Philippe;Imprimerie Falvigne;;;non;
Selvouze;Étienne;Ateliers Tavrenne;;;non;
Hulvichon;Louis;Ateliers Falvigne;;;non;
Urvoubre;Sylvie;Verrerie Vaubrenne;;;non;
Pruveval;Pierre;Comptoir Xandrelle;;;non;
Hulvotte;Guillaume;Verrerie Vaubrenne;;;non;
Abrevignac;Geneviève;Brasserie Morvalle;;;non;
Abrevault;Gabriel;Ateliers Rossolde;;animation;non;
Bralmignac;Amina;Fonderie Estrolle;;;non;
Bralmançon;Olivier;Comptoir Lavorneau;;;non;
Luskoubre;Marc;Verrerie Vaubrenne;;;non;
Abrevoubre;Geneviève;Menuiserie Gravelune;;;non;
Korvoubre;Raphaël;Verrerie Vaubrenne;;;non;
Mirvard;Pierre;Verrerie Ivoranche;;;non;
Jusvumet;Camille;Fonderie Estrolle;;;non;
Cloveval;Samuel;Menuiserie Ulvardes;;;non;
Selvière;Antoine;Coopérative Dravenne;;;non;
Jusvouze;Véronique;Verrerie Vaubrenne;;;non;
Neskivet;Paul;Comptoir Jolvane;;;non;
Dulvande;Samuel;Verrerie Pravette;;;non;
Bralmault;Antoine;Fabrique Sorbagne;;animation;non;
Abrevière;Émilie;Imprimerie Yzorne;;animation;non;
Selveval;Lucie;Filature Zelvaine;;;non;
Tolvichon;Olivier;Fonderie Xandrelle;;;non;
Hulvard;Fatima;Ateliers Estrolle;;;non;
Ivreux;Alexandre;Tannerie Aubrenc;;;non;
Korveval;Félix;Tannerie Nervanche;;;non;
Urvichon;Karim;Tannerie Nervanche;;;non;
Dulvichon;Émilie;Menuiserie Ulvardes;;;non;
Tolvouze;Luc;Fabrique Ivoranche;;;non;
Jusvivet;Claire;Imprimerie Yzorne;;animation;non;
Quilmault;Amina;Coopérative Jolvane;;;non;
Ivrouze;François;Fonderie Aubrenc;;;non;
Gorzumet;Hélène;Fonderie Aubrenc;;;non;
Ivrault;Sophie;Coopérative Morvalle;;;non;
Bralmière;Louise;Tannerie Quimvaux;;;non;
Korvande;Nicolas;Comptoir Sorbagne;;;non;
Ivrumet;Geneviève;Coopérative Jolvane;;;non;
Obresson;Geneviève;Coopérative Gravelune;;;non;
Obrançon;Guillaume;Papeterie Hostrenne;;;non;
Urvignac;Fatima;Ateliers Aubrenc;;;non;
Bralmotte;Claire;Ateliers Tavrenne;;;non;
Hulvignac;Félix;Imprimerie Hostrenne;;;non;
Clovotte;François;Coopérative Rossolde;;;non;
Dulvoubre;Philippe;Brasserie Belvarre;;;non;
Rozveron;Samuel;Fonderie Sorbagne;;;non;
Hulvoubre;Pierre;Menuiserie Olbrecq;;;non;
Urvouze;Émilie;Comptoir Gravelune;;;non;
Korvançon;Félix;Fonderie Ivoranche;;;non;
Neskande;Émilie;Coopérative Gravelune;;;non;
Mirvumet;Chantal;Menuiserie Gravelune;;;non;
Korvotte;Alexandre;Menuiserie Nervanche;;;non;
Eskeval;Zoé;Tannerie Nervanche;;;non;
Neskard;Camille;Menuiserie Gravelune;;animation;non;
Tolveron;Manon;Brasserie Belvarre;;animation;non;
Voskoubre;Nathalie;Fonderie Kalvenne;;;non;
Pruvière;Sophie;Ateliers Rossolde;;;non;
Obrichon;Paul;Ateliers Rossolde;;;non;
Abrevançon;Fatima;Tannerie Nervanche;;animation;non;
Eskière;Nathalie;Fonderie Xandrelle;;;non;
Ivreron;Caroline;Imprimerie Falvigne;;;non;
Neskoubre;Véronique;Fonderie Estrolle;;;non;
Urvivet;Anne;Fonderie Kalvenne;;;non;
Hulvançon;Olivier;Menuiserie Estrolle;;;non;
Clovançon;Karim;Fabrique Sorbagne;;;non;
Quilmotte;Zoé;Ateliers Aubrenc;;;non;
Eskouze;Félix;Filature Zelvaine;;;non;
Rozvichon;Amina;Verrerie Olbrecq;;;non;
Fralvière;Simon;Comptoir Sorbagne;;;non;
Vandergrolcquesmirtavelle-Ostrovaubelindigues;Marc;Tannerie Aubrenc;;animation;non;
Hulvière;Pierre;Papeterie Hostrenne;;;non;
Jusvault;Raphaël;Fabrique Sorbagne;;;non;
Dulvivet;Guillaume;Filature Olbrecq;;animation;non;
Fralvoubre;Pierre;Imprimerie Falvigne;;;non;
Selveux;Hélène;Brasserie Lavorneau;;;non;
Rozvançon;Caroline;Brasserie Belvarre;;;non;
Korvière;Olivier;Ateliers Falvigne;;;non;
Ivrichon;Nicolas;Fabrique Sorbagne;;;non;
Korvumet;Félix;Imprimerie Yzorne;;;non;
Voskande;Félix;Ateliers Falvigne;;animation;non;
Selvumet;Guillaume;Papeterie Estrolle;;;non;
Urvotte;Michel;Verrerie Ivoranche;;;non;
Eskault;Émilie;Coopérative Jolvane;;;non;
Bralmeval;Hélène;Brasserie Dravenne;;;non;
Hulvesson;Guillaume;Comptoir Jolvane;;;non;
Jusvoubre;Caroline;Fabrique Sorbagne;;;non;
Luskeux;Félix;Tannerie Belvarre;;;non;
Pruvouze;Antoine;Imprimerie Yzorne;;;non;
Quilmignac;Julie;Menuiserie Jolvane;;;non;
Tolvançon;Étienne;Menuiserie Estrolle;;animation;non;
Voskumet;Hugo;Ateliers Tavrenne;;;non;
Quilmumet;Manon;Fonderie Sorbagne;;;non;
Voskouze;Marc;Fonderie Estrolle;;;non;
Abrevard;Samuel;Filature Dravenne;;;non;
Luskichon;Guillaume;Brasserie Belvarre;;animation;non;
Clovesson;Manon;Fabrique Sorbagne;;;non;
Hulvumet;Simon;Ateliers Ivoranche;;;non;
Voskeron;Lucie;Verrerie Ivoranche;;;non;
Selvignac;Thomas;Brasserie Sorbagne;;animation;non;
Neskeron;Jean;Filature Zelvaine;;;non;
Pruvivet;Manon;Menuiserie Aubrenc;;;non;
Clovard;Sophie;Brasserie Belvarre;;;non;
Gorzeval;Étienne;Papeterie Ulvardes;;;non;
Ivroubre;Philippe;Ateliers Tavrenne;;animation;non;
Luskault;Gabriel;Verrerie Gravelune;;;non;
Korvard;Émilie;Verrerie Vaubrenne;;;non;
Quilmichon;Thomas;Fonderie Estrolle;;;non;
Eskançon;Alexandre;Menuiserie Hostrenne;;;non;
Luskançon;Émilie;Ateliers Tavrenne;;;non;
Dulvault;Marc;Verrerie Ivoranche;;;non;
Rozveval;Hélène;Comptoir Ivoranche;;;non;
Pruvançon;Julie;Imprimerie Yzorne;;;non;
Mirveron;Louis;Filature Wassorel;;;non;
Eskard;François;Fonderie Xandrelle;;;non;
Bralmouze;Hugo;Tannerie Falvigne;;animation;non;
Ivrançon;Louis;Coopérative Morvalle;;;non;
Clovivet;Karim;Fonderie Kalvenne;;;non;
Rozvande;Léa;Verrerie Olbrecq;;;non;
Hulvouze;Amina;Coopérative Dravenne;;;non;
Eskesson;Étienne;Menuiserie Ulvardes;;;non;
Abreveval;Philippe;Ateliers Falvigne;;;non;
Quilmeux;Émilie;Ateliers Falvigne;;;non;
Ivreval;Gabriel;Verrerie Vaubrenne;;animation;non;
Voskotte;Simon;Fabrique Ivoranche;;;non;
Obrignac;Chantal;Imprimerie Yzorne;;;non;
Eskotte;Guillaume;Verrerie Nervanche;;;non;
Obrard;Léa;Comptoir Sorbagne;;;non;
Gorzivet;Guillaume;Fonderie Estrolle;;;non;
Mirvançon;Geneviève;Brasserie Belvarre;;;non;
Selvotte;Julie;Comptoir Xandrelle;;;non;
Mirvesson;Amina;Brasserie Sorbagne;;;non;
Luskignac;Isabelle;Papeterie Estrolle;;;non;
Obrumet;Chloé;Comptoir Yzorne;;;non;
Abrevesson;Anne;Fonderie Estrolle;;;non;
Rozvumet;Fatima;Menuiserie Hostrenne;;;non;
Mirvoubre;Guillaume;Comptoir Sorbagne;;;non;
Rozvoubre;Véronique;Fonderie Xandrelle;;;non;
Gorzotte;Claire;Menuiserie Olbrecq;;;non;
Ivrière;Antoine;Fabrique Sorbagne;;;non;
Selvichon;Isabelle;Tannerie Nervanche;;;non;
Urvault;Isabelle;Comptoir Yzorne;;;non;
Voskesson;Julie;Brasserie Belvarre;;;non;
Abrevotte;Chantal;Ateliers Ivoranche;;;non;
Neskotte;Amina;Fabrique Kalvenne;;;non;
Voskançon;Étienne;Menuiserie Olbrecq;;;non;
Fralveron;Zoé;Menuiserie Sorbagne;;;non;
Rozvouze;Marc;Ateliers Estrolle;;;non;
Abreveux;Sylvie;Comptoir Jolvane;;;non;
Pruvignac;Karim;Menuiserie Estrolle;;;non;
Mirveval;Zoé;Fabrique Kalvenne;;;non;
Fralvignac;Philippe;Menuiserie Estrolle;;;non;
Gorzeux;Gabriel;Menuiserie Ulvardes;;;non;
Urveval;Jean;Menuiserie Quimvaux;;;non;
1 nom prenom appartenance courriel titre_pressenti exclu notes
2 Mirvault Isabelle Comptoir Gravelune non
3 Neskière Youssef Verrerie Vaubrenne animation non
4 Abrevande Félix Imprimerie Yzorne non
5 Fralvançon Hugo Brasserie Wassorel non
6 Fralveux Manon Menuiserie Estrolle non
7 Luskotte Catherine Fonderie Estrolle non
8 WAMMERWOLM-MAWWEMMEL Zoé Menuiserie Ulvardes non
9 Neskichon Louise Ateliers Rossolde animation non
10 Urvesson Catherine Fabrique Ulvardes non
11 Hulvivet Karim Fonderie Xandrelle non
12 Selveron Caroline Ateliers Cassorgue non
13 Korvault Catherine Tannerie Nervanche non
14 Fralvotte Caroline Fonderie Ivoranche non
15 Jusvesson Geneviève Tannerie Belvarre non
16 Abrevumet Paul Ateliers Aubrenc non
17 Fralvault Léa Fonderie Sorbagne non
18 Obrivet Fatima Fabrique Sorbagne non
19 Voskeval Philippe Menuiserie Estrolle animation non
20 Dulveval Zoé Menuiserie Quimvaux non
21 Korveux Paul Filature Olbrecq non
22 Luskière Julie Fabrique Sorbagne non
23 Clovande Émilie Ateliers Falvigne non
24 Hulvande Paul Verrerie Vaubrenne non
25 Fralvesson Hugo Filature Wassorel non
26 Urvière Isabelle Brasserie Belvarre non
27 Rozvotte Thomas Verrerie Ivoranche non
28 Bralmumet Isabelle Verrerie Aubrenc non
29 Dulvouze Catherine Ateliers Rossolde non
30 Voskichon Marc Menuiserie Estrolle non
31 Mirvivet Simon Ateliers Ivoranche non
32 Clovault Louise Imprimerie Yzorne non
33 Voskeux Lucie Coopérative Morvalle non
34 Eskignac Amina Brasserie Xandrelle non
35 Korvouze Fatima Ateliers Tavrenne animation non
36 Selvoubre Luc Brasserie Sorbagne non
37 Fralvichon Étienne Papeterie Ulvardes non
38 Voskivet Philippe Ateliers Tavrenne non
39 Quilmançon Louise Menuiserie Estrolle non
40 Luskande Sophie Fabrique Morvalle non
41 Abreveron Pierre Ateliers Rossolde non
42 Neskeux Philippe Comptoir Xandrelle non
43 Neskançon Amina Fabrique Ulvardes non
44 Tolvande Léa Ateliers Rossolde non
45 Quilmeron Antoine Fabrique Kalvenne non
46 Clovumet Guillaume Verrerie Pravette non
47 Eskeux Michel Papeterie Wassorel non
48 Hulvault Louise Coopérative Jolvane non
49 Hulveron Catherine Coopérative Rossolde animation non
50 Ivrivet François Menuiserie Sorbagne animation non
51 Eskumet Samuel Verrerie Ivoranche non
52 Fralvivet Claire Imprimerie Yzorne non
53 Mirveux Léa Menuiserie Wassorel non
54 Gorzesson Karim Papeterie Ulvardes non
55 Fralvumet Jean Ateliers Falvigne animation non
56 Urvançon Manon Ateliers Tavrenne animation non
57 Quilmande Sylvie Imprimerie Yzorne non
58 Gorzichon François Ateliers Estrolle non
59 Pruvesson Amina Fonderie Sorbagne non
60 Quilmière Lucie Fabrique Ivoranche non
61 Korvesson Fatima Ateliers Rossolde non
62 Selvesson Nicolas Imprimerie Yzorne non
63 Selvivet Louis Menuiserie Hostrenne animation non
64 Neskesson Louise Tannerie Belvarre non
65 Tolvesson Lucie Filature Zelvaine non
66 Quilmesson Olivier Comptoir Gravelune non
67 Luskivet Guillaume Brasserie Lavorneau non
68 Neskault Gabriel Menuiserie Tavrenne non
69 Urvumet Antoine Menuiserie Hostrenne non
70 Jusvançon Olivier Brasserie Morvalle non
71 Dulvançon Simon Menuiserie Ivoranche non
72 Gorzard Chloé Fabrique Sorbagne non
73 Pruveux Gabriel Papeterie Estrolle non
74 Clovière Manon Ateliers Ivoranche non
75 Obrouze François Tannerie Olbrecq animation non
76 Tolveval Karim Tannerie Belvarre non
77 Dulvumet Geneviève Menuiserie Quimvaux animation non
78 Bérêçàuvïlle-Dœûmôntëÿ Hugo Ateliers Falvigne non
79 Gorzeron Claire Menuiserie Hostrenne non
80 Luskumet Hélène Brasserie Xandrelle non
81 Fralvande Louis Ateliers Rossolde non
82 Selvault Émilie Fabrique Sorbagne non
83 Neskumet Léa Verrerie Aubrenc non
84 Korvichon Isabelle Menuiserie Ulvardes non
85 Obrande Michel Menuiserie Estrolle non
86 Luskeval Isabelle Brasserie Wassorel non
87 Tolvoubre Fatima Papeterie Ulvardes non
88 Gorzière Zoé Menuiserie Olbrecq non
89 Korvignac Sylvie Fabrique Ulvardes non
90 Obreval Antoine Ateliers Cassorgue non
91 Korveron Michel Fonderie Sorbagne animation non
92 Tolvumet Camille Fonderie Estrolle non
93 Dulvesson Marie Ateliers Cassorgue non
94 Jusvard Youssef Tannerie Aubrenc animation non
95 Tolvignac Guillaume Menuiserie Sorbagne non
96 Mirvotte Gabriel Tannerie Belvarre non
97 Urvard Marie Filature Wassorel animation non
98 Bralmesson Marie Coopérative Dravenne animation non
99 Neskignac Paul Brasserie Sorbagne non
100 Bralmeux Samuel Ateliers Rossolde animation non
101 Ivrotte Raphaël Coopérative Rossolde non
102 Luskouze Marc Ateliers Rossolde non
103 Jusvotte Sylvie Brasserie Belvarre non
104 Bralmoubre Véronique Fonderie Xandrelle non
105 Selvard Pierre Verrerie Vaubrenne non
106 Hulveux Marie Coopérative Rossolde non
107 Pruvotte Sophie Imprimerie Yzorne animation non
108 Fralveval Karim Coopérative Gravelune non
109 Gorzignac Philippe Imprimerie Falvigne non
110 Selvouze Étienne Ateliers Tavrenne non
111 Hulvichon Louis Ateliers Falvigne non
112 Urvoubre Sylvie Verrerie Vaubrenne non
113 Pruveval Pierre Comptoir Xandrelle non
114 Hulvotte Guillaume Verrerie Vaubrenne non
115 Abrevignac Geneviève Brasserie Morvalle non
116 Abrevault Gabriel Ateliers Rossolde animation non
117 Bralmignac Amina Fonderie Estrolle non
118 Bralmançon Olivier Comptoir Lavorneau non
119 Luskoubre Marc Verrerie Vaubrenne non
120 Abrevoubre Geneviève Menuiserie Gravelune non
121 Korvoubre Raphaël Verrerie Vaubrenne non
122 Mirvard Pierre Verrerie Ivoranche non
123 Jusvumet Camille Fonderie Estrolle non
124 Cloveval Samuel Menuiserie Ulvardes non
125 Selvière Antoine Coopérative Dravenne non
126 Jusvouze Véronique Verrerie Vaubrenne non
127 Neskivet Paul Comptoir Jolvane non
128 Dulvande Samuel Verrerie Pravette non
129 Bralmault Antoine Fabrique Sorbagne animation non
130 Abrevière Émilie Imprimerie Yzorne animation non
131 Selveval Lucie Filature Zelvaine non
132 Tolvichon Olivier Fonderie Xandrelle non
133 Hulvard Fatima Ateliers Estrolle non
134 Ivreux Alexandre Tannerie Aubrenc non
135 Korveval Félix Tannerie Nervanche non
136 Urvichon Karim Tannerie Nervanche non
137 Dulvichon Émilie Menuiserie Ulvardes non
138 Tolvouze Luc Fabrique Ivoranche non
139 Jusvivet Claire Imprimerie Yzorne animation non
140 Quilmault Amina Coopérative Jolvane non
141 Ivrouze François Fonderie Aubrenc non
142 Gorzumet Hélène Fonderie Aubrenc non
143 Ivrault Sophie Coopérative Morvalle non
144 Bralmière Louise Tannerie Quimvaux non
145 Korvande Nicolas Comptoir Sorbagne non
146 Ivrumet Geneviève Coopérative Jolvane non
147 Obresson Geneviève Coopérative Gravelune non
148 Obrançon Guillaume Papeterie Hostrenne non
149 Urvignac Fatima Ateliers Aubrenc non
150 Bralmotte Claire Ateliers Tavrenne non
151 Hulvignac Félix Imprimerie Hostrenne non
152 Clovotte François Coopérative Rossolde non
153 Dulvoubre Philippe Brasserie Belvarre non
154 Rozveron Samuel Fonderie Sorbagne non
155 Hulvoubre Pierre Menuiserie Olbrecq non
156 Urvouze Émilie Comptoir Gravelune non
157 Korvançon Félix Fonderie Ivoranche non
158 Neskande Émilie Coopérative Gravelune non
159 Mirvumet Chantal Menuiserie Gravelune non
160 Korvotte Alexandre Menuiserie Nervanche non
161 Eskeval Zoé Tannerie Nervanche non
162 Neskard Camille Menuiserie Gravelune animation non
163 Tolveron Manon Brasserie Belvarre animation non
164 Voskoubre Nathalie Fonderie Kalvenne non
165 Pruvière Sophie Ateliers Rossolde non
166 Obrichon Paul Ateliers Rossolde non
167 Abrevançon Fatima Tannerie Nervanche animation non
168 Eskière Nathalie Fonderie Xandrelle non
169 Ivreron Caroline Imprimerie Falvigne non
170 Neskoubre Véronique Fonderie Estrolle non
171 Urvivet Anne Fonderie Kalvenne non
172 Hulvançon Olivier Menuiserie Estrolle non
173 Clovançon Karim Fabrique Sorbagne non
174 Quilmotte Zoé Ateliers Aubrenc non
175 Eskouze Félix Filature Zelvaine non
176 Rozvichon Amina Verrerie Olbrecq non
177 Fralvière Simon Comptoir Sorbagne non
178 Vandergrolcquesmirtavelle-Ostrovaubelindigues Marc Tannerie Aubrenc animation non
179 Hulvière Pierre Papeterie Hostrenne non
180 Jusvault Raphaël Fabrique Sorbagne non
181 Dulvivet Guillaume Filature Olbrecq animation non
182 Fralvoubre Pierre Imprimerie Falvigne non
183 Selveux Hélène Brasserie Lavorneau non
184 Rozvançon Caroline Brasserie Belvarre non
185 Korvière Olivier Ateliers Falvigne non
186 Ivrichon Nicolas Fabrique Sorbagne non
187 Korvumet Félix Imprimerie Yzorne non
188 Voskande Félix Ateliers Falvigne animation non
189 Selvumet Guillaume Papeterie Estrolle non
190 Urvotte Michel Verrerie Ivoranche non
191 Eskault Émilie Coopérative Jolvane non
192 Bralmeval Hélène Brasserie Dravenne non
193 Hulvesson Guillaume Comptoir Jolvane non
194 Jusvoubre Caroline Fabrique Sorbagne non
195 Luskeux Félix Tannerie Belvarre non
196 Pruvouze Antoine Imprimerie Yzorne non
197 Quilmignac Julie Menuiserie Jolvane non
198 Tolvançon Étienne Menuiserie Estrolle animation non
199 Voskumet Hugo Ateliers Tavrenne non
200 Quilmumet Manon Fonderie Sorbagne non
201 Voskouze Marc Fonderie Estrolle non
202 Abrevard Samuel Filature Dravenne non
203 Luskichon Guillaume Brasserie Belvarre animation non
204 Clovesson Manon Fabrique Sorbagne non
205 Hulvumet Simon Ateliers Ivoranche non
206 Voskeron Lucie Verrerie Ivoranche non
207 Selvignac Thomas Brasserie Sorbagne animation non
208 Neskeron Jean Filature Zelvaine non
209 Pruvivet Manon Menuiserie Aubrenc non
210 Clovard Sophie Brasserie Belvarre non
211 Gorzeval Étienne Papeterie Ulvardes non
212 Ivroubre Philippe Ateliers Tavrenne animation non
213 Luskault Gabriel Verrerie Gravelune non
214 Korvard Émilie Verrerie Vaubrenne non
215 Quilmichon Thomas Fonderie Estrolle non
216 Eskançon Alexandre Menuiserie Hostrenne non
217 Luskançon Émilie Ateliers Tavrenne non
218 Dulvault Marc Verrerie Ivoranche non
219 Rozveval Hélène Comptoir Ivoranche non
220 Pruvançon Julie Imprimerie Yzorne non
221 Mirveron Louis Filature Wassorel non
222 Eskard François Fonderie Xandrelle non
223 Bralmouze Hugo Tannerie Falvigne animation non
224 Ivrançon Louis Coopérative Morvalle non
225 Clovivet Karim Fonderie Kalvenne non
226 Rozvande Léa Verrerie Olbrecq non
227 Hulvouze Amina Coopérative Dravenne non
228 Eskesson Étienne Menuiserie Ulvardes non
229 Abreveval Philippe Ateliers Falvigne non
230 Quilmeux Émilie Ateliers Falvigne non
231 Ivreval Gabriel Verrerie Vaubrenne animation non
232 Voskotte Simon Fabrique Ivoranche non
233 Obrignac Chantal Imprimerie Yzorne non
234 Eskotte Guillaume Verrerie Nervanche non
235 Obrard Léa Comptoir Sorbagne non
236 Gorzivet Guillaume Fonderie Estrolle non
237 Mirvançon Geneviève Brasserie Belvarre non
238 Selvotte Julie Comptoir Xandrelle non
239 Mirvesson Amina Brasserie Sorbagne non
240 Luskignac Isabelle Papeterie Estrolle non
241 Obrumet Chloé Comptoir Yzorne non
242 Abrevesson Anne Fonderie Estrolle non
243 Rozvumet Fatima Menuiserie Hostrenne non
244 Mirvoubre Guillaume Comptoir Sorbagne non
245 Rozvoubre Véronique Fonderie Xandrelle non
246 Gorzotte Claire Menuiserie Olbrecq non
247 Ivrière Antoine Fabrique Sorbagne non
248 Selvichon Isabelle Tannerie Nervanche non
249 Urvault Isabelle Comptoir Yzorne non
250 Voskesson Julie Brasserie Belvarre non
251 Abrevotte Chantal Ateliers Ivoranche non
252 Neskotte Amina Fabrique Kalvenne non
253 Voskançon Étienne Menuiserie Olbrecq non
254 Fralveron Zoé Menuiserie Sorbagne non
255 Rozvouze Marc Ateliers Estrolle non
256 Abreveux Sylvie Comptoir Jolvane non
257 Pruvignac Karim Menuiserie Estrolle non
258 Mirveval Zoé Fabrique Kalvenne non
259 Fralvignac Philippe Menuiserie Estrolle non
260 Gorzeux Gabriel Menuiserie Ulvardes non
261 Urveval Jean Menuiserie Quimvaux non

View file

@ -0,0 +1,13 @@
nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes
Bralmumet;Caroline;Ateliers Jolvane;;;non;
Neskault;Hugo;Tannerie Cassorgue;;;non;
Ivrotte;Youssef;Verrerie Ulvardes;;;non;
Selvière;Louis;Tannerie Cassorgue;;;non;
Selvançon;Youssef;Ateliers Jolvane;;;non;
Quilmoubre;Amina;Verrerie Ulvardes;;;non;
Voskotte;Julie;Verrerie Ulvardes;;;non;
Neskivet;Paul;Ateliers Jolvane;;;non;
Bralmignac;Michel;Tannerie Cassorgue;;;non;
Tolveval;Zoé;Verrerie Ulvardes;;;non;
Urvard;Luc;Tannerie Cassorgue;;;non;
Mirveval;Véronique;Ateliers Jolvane;;;non;
1 nom prenom appartenance courriel titre_pressenti exclu notes
2 Bralmumet Caroline Ateliers Jolvane non
3 Neskault Hugo Tannerie Cassorgue non
4 Ivrotte Youssef Verrerie Ulvardes non
5 Selvière Louis Tannerie Cassorgue non
6 Selvançon Youssef Ateliers Jolvane non
7 Quilmoubre Amina Verrerie Ulvardes non
8 Voskotte Julie Verrerie Ulvardes non
9 Neskivet Paul Ateliers Jolvane non
10 Bralmignac Michel Tannerie Cassorgue non
11 Tolveval Zoé Verrerie Ulvardes non
12 Urvard Luc Tannerie Cassorgue non
13 Mirveval Véronique Ateliers Jolvane non

View file

@ -0,0 +1,351 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les fichiers livrés que le catalogue des démonstrations détermine (§ 10.3,
// § 15.5) : le fichier d'état de chaque démonstration, sous
// src/demo/livrees/ ; l'export CSV de la liste des participants de la petite
// et de la grande, et la variante windows-1252 du CSV des cas limites, sous
// exemples/. Le CSV des cas limites s'écrit à la main : le script le lit et
// ne l'écrit jamais.
//
// node scripts/engendrer_livrables.js [destination]
//
// La destination, la racine du projet par défaut, reçoit les sept fichiers
// sous leurs chemins relatifs, et rien n'est écrit ailleurs ; un chemin
// relatif se lit depuis le répertoire courant. Tout se calcule avant la
// première écriture. Le fichier livré est la source de vérité (§ 15.5,
// point 5) : src/demo/livrees.long.test.js rejoue ce script et compare
// l'empreinte de chaque charge à celle du fichier livré. Lancer le script
// est donc le geste conscient qui change une démonstration : il imprime,
// pour chaque fichier, s'il est créé, inchangé ou réécrit, et l'empreinte de
// la charge de chaque fichier d'état. Ni horloge ni tirage n'y entrent.
//
// Une démonstration livrée est la configuration de son entrée du catalogue,
// mise en charge (§ 4, § 8.8) :
// - l'événement demo:<clé>, au nom du catalogue, sans date, en brouillon et
// sans filiation ;
// - les sièges par défaut : la capacité la plus fréquente des tables, la
// plus grande à égalité ; une table d'une autre capacité porte la sienne
// en surcharge (§ 6.1) ;
// - les tables rondes, rangées en grille dans l'ordre de la configuration,
// rangée par rangée, leurs centres espacés de 300 cm ; la grille a la plus
// petite largeur dont le carré contient toutes les tables, et seule sa
// dernière rangée est incomplète ;
// - les contraintes de la configuration, les sièges non attribués, la
// génération par défaut de la recherche ;
// - chaque réservation, celle d'un animateur, au siège 1 de sa table pour
// tous les tours, ce siège titré « animation », et le titre pressenti
// « animation » porté par l'animateur ;
// - aucune proposition : le placement n'est pas figé (§ 15.5, point 6).
// Son fichier d'état s'écrit à la révision 1, sans journal, sous la version
// de la construction qui écrit (§ 18.6).
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { decoder } from '../src/csv/encodage.js';
import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js';
import { decouper } from '../src/csv/lecture.js';
import { CATALOGUE } from '../src/demo/catalogue.js';
import { serialiser, serialiserCharge } from '../src/stockage/canonique.js';
import { analyser, creerCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js';
const RACINE = resolve(fileURLToPath(new URL('..', import.meta.url)));
// Chemins des livrables, relatifs à la racine, séparés par « / ».
const DOSSIER_LIVREES = 'src/demo/livrees';
const CAS_LIMITES = 'exemples/participants_cas_limites.csv';
const CAS_LIMITES_CP1252 = 'exemples/participants_cas_limites_cp1252.csv';
const csvDeDemonstration = (cle) => `exemples/participants_demo_${cle}.csv`;
// Démonstrations dont la liste des participants est livrée en CSV, dans
// l'ordre du § 10.3.
const DEMONSTRATIONS_EN_CSV = ['petite', 'grande'];
// Séparateur de la variante windows-1252 (§ 10.3).
const VIRGULE = ',';
// Écart entre les centres de deux tables voisines de la grille, en cm.
const ECART_CM = 300;
// Le siège de l'animateur, et le titre qu'il porte.
const SIEGE_ANIMATEUR = 1;
const ANIMATION = 'animation';
const USAGE = 'usage : node scripts/engendrer_livrables.js [destination]';
// Capacité la plus fréquente parmi les tables, la plus grande à égalité ; le
// résultat ne dépend pas de l'ordre des tables.
function capaciteParDefaut(tables) {
const effectifs = new Map();
for (const { capacite } of tables) effectifs.set(capacite, (effectifs.get(capacite) ?? 0) + 1);
let defaut = null;
for (const [capacite, effectif] of effectifs) {
const effectifDuDefaut = effectifs.get(defaut) ?? 0;
if (effectif > effectifDuDefaut || (effectif === effectifDuDefaut && capacite > defaut)) defaut = capacite;
}
return defaut;
}
// Largeur de la grille de nombre tables : la plus petite largeur c telle
// que c × c ≥ nombre.
function colonnesDe(nombre) {
let colonnes = 1;
while (colonnes * colonnes < nombre) colonnes += 1;
return colonnes;
}
// Plus grand identifiant d'une liste d'enregistrements ; 0 pour une liste
// vide.
const plusGrandId = (enregistrements) => enregistrements.reduce((plus, { id }) => Math.max(plus, id), 0);
// Animateur de chaque table réservée, par identifiant de table, dans l'ordre
// des réservations. Lève quand une réservation ne vaut pas pour tous les
// tours, ou qu'une table en reçoit deux : le siège 1 ne porte qu'un
// animateur, et le script n'écrit que ce que le catalogue produit.
function animateursDe(reservations, cle) {
const animateurs = new Map();
for (const { participant, table, portee } of reservations) {
if (portee !== 'tous' || animateurs.has(table)) {
throw new Error(
`démonstration ${cle} : la table ${table} reçoit au plus un animateur, réservé pour tous les tours`,
);
}
animateurs.set(table, participant);
}
return animateurs;
}
/**
* Charge de la démonstration d'une entrée du catalogue, telle que l'en-tête
* du module la décrit. La configuration est construite une fois ; la charge
* rendue est faite d'objets neufs.
*
* @param {{cle: string, nom: string, construire: () => import('../src/moteur/types.js').Configuration}} entree
* @returns {import('../src/stockage/types.js').Charge}
* @throws {Error} quand une réservation ne vaut pas pour tous les tours, ou
* qu'une table en reçoit deux
*/
export function chargeDeDemonstration({ cle, nom, construire }) {
const { participants, tables, tours, reservations, contraintes } = construire();
const siegesParDefaut = capaciteParDefaut(tables);
const neuve = creerCharge({ id: `demo:${cle}`, nom, date: null, siegesParDefaut, tours });
const animateurs = animateursDe(reservations, cle);
const reserves = new Set(animateurs.values());
const colonnes = colonnesDe(tables.length);
const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes;
return {
...neuve,
reglages: { ...neuve.reglages, separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances },
prochainsIds: { participant: plusGrandId(participants) + 1, table: plusGrandId(tables) + 1, proposition: 1 },
participants: participants.map((participant) => ({
id: participant.id,
nom: participant.nom,
prenom: participant.prenom ?? null,
appartenance: participant.appartenance,
courriel: null,
titrePressenti: reserves.has(participant.id) ? ANIMATION : null,
notes: null,
exclu: participant.exclu === true,
})),
tables: tables.map(({ id, numero, capacite }, rang) => ({
id,
numero,
sieges: capacite === siegesParDefaut ? null : capacite,
forme: 'ronde',
position: { x: (rang % colonnes) * ECART_CM, y: Math.floor(rang / colonnes) * ECART_CM },
})),
reservations: [...animateurs].map(([table, participant]) => ({
participant,
table,
siege: SIEGE_ANIMATEUR,
portee: 'tous',
tour: null,
})),
titres: [...animateurs.keys()].map((table) => ({ table, siege: SIEGE_ANIMATEUR, libelle: ANIMATION })),
};
}
/**
* Texte du fichier d'état livré d'une entrée du catalogue : sa charge, en
* forme canonique, à la révision 1, sous la version de la construction qui
* écrit.
*
* @param {{cle: string, nom: string, construire: Function}} entree
* @param {{produitVersion?: string}} [options] version affichée de la
* construction, VERSION.affichee par défaut
* @returns {string}
*/
export function texteDeDemonstration(entree, { produitVersion = VERSION.affichee } = {}) {
return serialiser(chargeDeDemonstration(entree), { revision: 1, produitVersion });
}
/**
* Empreinte d'une charge (§ 15.5, point 5) : SHA-256 de son texte canonique
* seul, en 64 chiffres hexadécimaux minuscules. L'en-tête n'y entre pas : la
* version de la construction qui écrit ne la change pas.
*
* @param {import('../src/stockage/types.js').Charge} charge
* @returns {string}
*/
export function empreinte(charge) {
return createHash('sha256').update(serialiserCharge(charge)).digest('hex');
}
// Octet windows-1252 de chaque caractère que ce codage représente, lu dans
// le décodeur windows-1252 lui-même, octet par octet : l'encodage en est
// l'inverse exact. 00 à 7F donnent l'ASCII, A0 à FF les mêmes points de
// code, 80 à 9F les signes propres à windows-1252, dont « € », « œ » et
// l'apostrophe typographique.
const DECODEUR_1252 = new TextDecoder('windows-1252');
const OCTET_1252 = new Map(
Array.from({ length: 256 }, (_, octet) => [DECODEUR_1252.decode(Uint8Array.of(octet)), octet]),
);
/**
* Octets windows-1252 d'un texte, un par caractère, sans marque.
*
* @param {string} texte
* @returns {Uint8Array}
* @throws {RangeError} pour un caractère que windows-1252 ne représente pas,
* nommé par son point de code et son rang dans le texte, en unités UTF-16
*/
export function enWindows1252(texte) {
const octets = [];
let rang = 0;
for (const caractere of texte) {
const octet = OCTET_1252.get(caractere);
if (octet === undefined) {
const point = caractere.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
throw new RangeError(`U+${point}, au rang ${rang} : caractère absent de windows-1252`);
}
octets.push(octet);
rang += caractere.length;
}
return Uint8Array.from(octets);
}
/**
* Variante windows-1252 du CSV des cas limites (§ 10.3) : ses
* enregistrements, découpés sous « ; », réécrits sous « , » par l'écriture
* unique des CSV, puis encodés en windows-1252, sans marque. Chaque champ
* garde son texte ; un champ qui porte la virgule, un guillemet ou une fin
* de ligne s'écrit cité.
*
* @param {Uint8Array} octets le fichier écrit à la main, que decoder lit
* @returns {Uint8Array}
* @throws {Error} quand un guillemet reste ouvert à la fin du fichier
*/
export function varianteWindows1252(octets) {
const { enregistrements, guillemetOuvert } = decouper(decoder(octets).texte, SEPARATEUR);
if (guillemetOuvert) throw new Error(`${CAS_LIMITES} : un guillemet reste ouvert à la fin du fichier`);
// ecrireCsv écrit de l'UTF-8 avec marque, que le décodeur UTF-8 retire.
return enWindows1252(new TextDecoder().decode(ecrireCsv(enregistrements, VIRGULE)));
}
/**
* Les sept livrables, calculés sans rien écrire : { chemin, octets,
* empreinte } ; le chemin est relatif à la racine, séparé par « / » ;
* l'empreinte, celle de la charge d'un fichier d'état, null pour un CSV. Dans
* l'ordre : le fichier d'état de chaque démonstration, dans l'ordre du
* catalogue ; l'export de la liste des participants de la petite, puis de la
* grande, chacun lu dans le texte du fichier d'état de sa démonstration ; la
* variante windows-1252 du CSV des cas limites.
*
* @param {{casLimites: Uint8Array, produitVersion?: string}} options
* casLimites : les octets du CSV des cas limites, écrit à la main
* @returns {Array<{chemin: string, octets: Uint8Array, empreinte: string|null}>}
*/
export function livrables({ casLimites, produitVersion = VERSION.affichee }) {
const etats = CATALOGUE.map((entree) => {
const texte = texteDeDemonstration(entree, { produitVersion });
return { cle: entree.cle, texte, charge: analyser(texte).charge };
});
const fichiers = etats.map(({ cle, texte, charge }) => ({
chemin: `${DOSSIER_LIVREES}/${cle}.gtt.json`,
octets: new TextEncoder().encode(texte),
empreinte: empreinte(charge),
}));
for (const cle of DEMONSTRATIONS_EN_CSV) {
const etat = etats.find((candidat) => candidat.cle === cle);
if (etat === undefined) throw new Error(`aucune démonstration ${cle} au catalogue`);
fichiers.push({
chemin: csvDeDemonstration(cle),
octets: exporterParticipants(etat.charge.participants),
empreinte: null,
});
}
fichiers.push({ chemin: CAS_LIMITES_CP1252, octets: varianteWindows1252(casLimites), empreinte: null });
return fichiers;
}
// Octets d'un fichier, ou null quand il n'existe pas.
function lireSiPresent(chemin) {
try {
return readFileSync(chemin);
} catch (erreur) {
if (erreur.code === 'ENOENT') return null;
throw erreur;
}
}
/**
* Écrit les sept livrables sous destination, après les avoir tous calculés,
* et rien ailleurs. Le CSV des cas limites se lit sous source. Rend chaque
* livrable avec son état : « créé » quand le fichier n'existait pas,
* « inchangé » quand il portait déjà ces octets, « réécrit » sinon.
*
* @param {string} destination racine qui reçoit les fichiers
* @param {{source?: string, produitVersion?: string}} [options] source : la
* racine du projet par défaut
* @returns {Array<{chemin: string, octets: Uint8Array, empreinte: string|null, etat: string}>}
*/
export function ecrireLivrables(destination, { source = RACINE, produitVersion } = {}) {
const casLimites = readFileSync(join(source, ...CAS_LIMITES.split('/')));
return livrables({ casLimites, produitVersion }).map((fichier) => {
const cible = join(destination, ...fichier.chemin.split('/'));
const avant = lireSiPresent(cible);
mkdirSync(dirname(cible), { recursive: true });
writeFileSync(cible, fichier.octets);
const etat = avant === null ? 'créé' : Buffer.compare(avant, fichier.octets) === 0 ? 'inchangé' : 'réécrit';
return { ...fichier, etat };
});
}
// Ligne de commande : une destination au plus, la racine par défaut. Sort à
// 0 après l'écriture, à 1 quand un livrable ne se calcule ou ne s'écrit pas,
// à 2 sur un usage fautif.
function executer(arguments_) {
if (arguments_.length > 1 || arguments_.some((argument) => argument.startsWith('-'))) {
console.error(USAGE);
return 2;
}
const destination = arguments_.length === 1 ? resolve(arguments_[0]) : RACINE;
let ecrits;
try {
ecrits = ecrireLivrables(destination);
} catch (erreur) {
console.error(`Livrables : ${erreur.message}`);
return 1;
}
console.log(`Livrables écrits sous ${destination}, construction ${VERSION.affichee} :`);
for (const { chemin, octets, empreinte: hachage, etat } of ecrits) {
const suite = hachage === null ? '' : `, empreinte de la charge ${hachage}`;
console.log(` ${etat.padEnd(9)}${chemin} (${octets.length} octets${suite})`);
}
return 0;
}
// Vrai quand ce fichier est le script que Node a lancé, et non un module
// importé.
function estLanceDirectement() {
if (process.argv[1] === undefined) return false;
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (estLanceDirectement()) process.exitCode = executer(process.argv.slice(2));

View file

@ -0,0 +1,491 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du script qui engendre les fichiers livrés (§ 10.3, § 15.5) : la
// charge de chaque démonstration — la configuration du catalogue,
// l'événement demo:<clé>, la salle en grille, les animateurs au siège 1 —,
// le fichier d'état qu'il en écrit, la variante windows-1252 du CSV des cas
// limites, et la ligne de commande, qui écrit dans le dossier donné et nulle
// part ailleurs, et réécrit les mêmes octets quand on la relance. « Nulle
// part ailleurs » s'éprouve sur une copie du projet, photographiée avant et
// après chaque lancement : tout fichier écrit hors des sept livrables s'y
// voit, le CSV des cas limites, que le script lit sans l'écrire, comme les
// autres. Le contenu des fichiers livrés eux-mêmes s'éprouve à côté d'eux,
// dans src/demo/livrees.test.js et src/demo/livrees.long.test.js.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { exporterParticipants } from '../src/csv/export.js';
import { decouper } from '../src/csv/lecture.js';
import { CATALOGUE } from '../src/demo/catalogue.js';
import { serialiser } from '../src/stockage/canonique.js';
import { GENERATION_PAR_DEFAUT, analyser, configurationDepuisCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js';
import { describe, test } from '../test/lanceur.js';
import { versionVoisine } from '../test/version_voisine.js';
import {
chargeDeDemonstration,
enWindows1252,
livrables,
texteDeDemonstration,
varianteWindows1252,
} from './engendrer_livrables.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const SCRIPT = fileURLToPath(new URL('./engendrer_livrables.js', import.meta.url));
const CAS_LIMITES = join(RACINE, 'exemples', 'participants_cas_limites.csv');
// Les sept livrables que le script écrit, chemins relatifs à la racine, dans
// l'ordre où il les écrit : un fichier d'état par démonstration du
// catalogue, les deux CSV de démonstration, puis la variante windows-1252 du
// CSV des cas limites (§ 10.3).
const LIVRABLES = [
...CATALOGUE.map(({ cle }) => `src/demo/livrees/${cle}.gtt.json`),
'exemples/participants_demo_petite.csv',
'exemples/participants_demo_grande.csv',
'exemples/participants_cas_limites_cp1252.csv',
];
// Ce que le script lit, chemins relatifs à la racine du projet : package.json,
// qui déclare les modules ES, le script, le code de src/ qu'il importe et les
// CSV de exemples/, celui des cas limites compris, qu'il lit sans l'écrire.
const COPIES = ['package.json', 'scripts/engendrer_livrables.js', 'src', 'exemples'];
// Date de modification, en secondes, de chaque fichier copié : un instant
// ancien. Toute écriture ultérieure la déplace, d'octets identiques comprise,
// quelle que soit la finesse de l'horloge du système de fichiers.
const DATE_ANCIENNE = 1_000_000_000;
const MARQUE = '\u{FEFF}';
// Entrée du catalogue de clé donnée.
const entreeDe = (cle) => CATALOGUE.find((entree) => entree.cle === cle);
// Entrée d'essai : la petite démonstration, sa configuration retouchée par
// retoucher, qui reçoit une copie et la rend.
const entreeRetouchee = (retoucher) => ({
cle: 'essai',
nom: 'Essai',
construire: () => retoucher(entreeDe('petite').construire()),
});
const parTable = (a, b) => a.table - b.table;
// Dossier temporaire neuf, passé à faire, puis effacé ; rend ce que rend
// faire.
function avecDossier(faire) {
const dossier = mkdtempSync(join(tmpdir(), 'livrables-'));
try {
return faire(dossier);
} finally {
rmSync(dossier, { recursive: true, force: true });
}
}
// Fichiers d'un dossier, à toute profondeur, en chemins relatifs séparés par
// « / », triés.
const fichiersSous = (dossier) =>
readdirSync(dossier, { recursive: true, withFileTypes: true })
.filter((entree) => entree.isFile())
.map((entree) => relative(dossier, join(entree.parentPath, entree.name)).split(sep).join('/'))
.sort();
// Instantané d'un dossier : la taille et la date de modification, en
// nanosecondes, de chaque fichier à toute profondeur, sous son chemin relatif
// séparé par « / ».
const instantane = (dossier) =>
new Map(
fichiersSous(dossier).map((chemin) => {
const { size, mtimeNs } = statSync(join(dossier, chemin), { bigint: true });
return [chemin, `${size} octets, modifié à ${mtimeNs} ns`];
}),
);
// Chemins que deux instantanés distinguent, triés : un fichier créé, retiré,
// ou dont la taille ou la date de modification change. Un fichier réécrit avec
// les mêmes octets en fait partie, sa date de modification avance.
const differences = (avant, apres) =>
[...new Set([...avant.keys(), ...apres.keys()])].filter((chemin) => avant.get(chemin) !== apres.get(chemin)).sort();
// Lance le script de chemin donné depuis le répertoire cwd ; rend son statut
// et ses sorties.
const lancerScript = (script, cwd, ...arguments_) =>
spawnSync(process.execPath, [script, ...arguments_], { cwd, encoding: 'utf8' });
// Lance le script du projet depuis le répertoire cwd.
const lancer = (cwd, ...arguments_) => lancerScript(SCRIPT, cwd, ...arguments_);
// Dossier temporaire neuf qui porte, sous projet/, une copie de ce que le
// script lit, chaque fichier daté de DATE_ANCIENNE. Passe à faire ce dossier
// et lancerCopie, qui lance depuis lui le script copié ; efface le dossier ;
// rend ce que rend faire. Le script y est à la racine de son arbre, comme
// dans le projet : un fichier qu'il écrit hors de la destination tombe dans
// ce dossier, copie du projet ou répertoire courant, jamais dans le projet.
function avecProjetCopie(faire) {
return avecDossier((dossier) => {
for (const chemin of COPIES) {
cpSync(join(RACINE, chemin), join(dossier, 'projet', chemin), { recursive: true });
}
for (const chemin of fichiersSous(dossier)) utimesSync(join(dossier, chemin), DATE_ANCIENNE, DATE_ANCIENNE);
const copie = join(dossier, 'projet', 'scripts', 'engendrer_livrables.js');
return faire(dossier, (...arguments_) => lancerScript(copie, dossier, ...arguments_));
});
}
const echapper = (texte) => texte.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
describe("engendrer_livrables : la charge d'une démonstration (§ 15)", () => {
test('porte la configuration du catalogue : participants, capacités, tours, réservations, contraintes', () => {
for (const entree of CATALOGUE) {
const configuration = entree.construire();
const charge = chargeDeDemonstration(entree);
const lue = configurationDepuisCharge(charge);
assert.deepEqual(
{ ...lue, reservations: [...lue.reservations].sort(parTable) },
{
...configuration,
participants: configuration.participants.map(({ id, nom, appartenance }) => ({
id,
nom,
appartenance,
exclu: false,
})),
reservations: [...configuration.reservations].sort(parTable),
},
entree.cle,
);
assert.deepEqual(
charge.participants.map(({ prenom }) => prenom),
configuration.participants.map(({ prenom }) => prenom),
entree.cle,
);
}
});
test("l'événement demo:<clé> : nom du catalogue, sans date, en brouillon, sans filiation, les tours et le défaut de sièges de la configuration", () => {
// Sièges par défaut des quatre démonstrations (§ 15.1, § 15.3).
const defauts = new Map([['grande', 8], ['grande-sans-exception', 8], ['petite', 3], ['petite-conflit', 3]]);
assert.deepEqual(CATALOGUE.map(({ cle }) => cle).sort(), [...defauts.keys()].sort());
for (const entree of CATALOGUE) {
assert.deepEqual(
chargeDeDemonstration(entree).evenement,
{
id: `demo:${entree.cle}`,
nom: entree.nom,
date: null,
siegesParDefaut: defauts.get(entree.cle),
tours: entree.construire().tours,
unite: 'cm',
etat: 'brouillon',
filiation: null,
},
entree.cle,
);
}
});
test('les réglages : les contraintes de la configuration, une à une, les sièges non attribués, la génération par défaut', () => {
for (const entree of CATALOGUE) {
assert.deepEqual(
chargeDeDemonstration(entree).reglages,
{ ...entree.construire().contraintes, attribuerSieges: false, generation: { ...GENERATION_PAR_DEFAUT } },
entree.cle,
);
}
const contraintes = {
separerAppartenances: false,
nouveauxVoisins: true,
nouvelleTable: false,
varierAppartenances: true,
};
const entree = entreeRetouchee((configuration) => ({ ...configuration, contraintes }));
assert.deepEqual(chargeDeDemonstration(entree).reglages, {
...contraintes,
attribuerSieges: false,
generation: { ...GENERATION_PAR_DEFAUT },
});
});
test('les tables : rondes, en grille rangée par rangée, centres à 300 cm, la dernière rangée seule incomplète', () => {
assert.deepEqual(chargeDeDemonstration(entreeDe('petite')).tables, [
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
{ id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } },
{ id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 0, y: 300 } },
{ id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 300, y: 300 } },
]);
// 33 tables : six colonnes, cinq rangées pleines, puis trois tables.
const { tables } = chargeDeDemonstration(entreeDe('grande'));
const positions = tables.map(({ position: { x, y } }) => [x, y]);
assert.deepEqual(positions.slice(0, 7), [[0, 0], [300, 0], [600, 0], [900, 0], [1200, 0], [1500, 0], [0, 300]]);
assert.deepEqual(positions.slice(-4), [[1500, 1200], [0, 1500], [300, 1500], [600, 1500]]);
assert.equal(new Set(positions.map(String)).size, 33);
assert.ok(tables.every(({ forme }) => forme === 'ronde'));
assert.deepEqual(tables.map(({ id, numero }) => [id, numero]), positions.map((_, rang) => [rang + 1, rang + 1]));
});
test("les sièges : le défaut est la capacité la plus fréquente, la plus grande à égalité ; toute autre capacité surcharge sa table (§ 6.1)", () => {
const surcharges = (charge) =>
charge.tables.filter(({ sieges }) => sieges !== null).map(({ numero, sieges }) => [numero, sieges]);
assert.deepEqual(surcharges(chargeDeDemonstration(entreeDe('grande'))), [[30, 7], [31, 7], [32, 7], [33, 7]]);
for (const cle of ['grande-sans-exception', 'petite', 'petite-conflit']) {
assert.deepEqual(surcharges(chargeDeDemonstration(entreeDe(cle))), [], cle);
}
const capacites = [6, 9, 6, 9];
const egalite = chargeDeDemonstration(
entreeRetouchee((configuration) => ({
...configuration,
tables: configuration.tables.map((table, rang) => ({ ...table, capacite: capacites[rang] })),
})),
);
assert.equal(egalite.evenement.siegesParDefaut, 9);
assert.deepEqual(surcharges(egalite), [[1, 6], [3, 6]]);
});
test("grandes : chaque animateur réservé au siège 1 de sa table pour tous les tours, ce siège titré « animation », le titre pressenti porté par lui seul ; petites : ni réservation ni titre", () => {
for (const cle of ['grande', 'grande-sans-exception']) {
const entree = entreeDe(cle);
const animateurs = entree.construire().reservations;
const charge = chargeDeDemonstration(entree);
assert.equal(animateurs.length, 33, cle);
assert.deepEqual(
[...charge.reservations].sort(parTable),
animateurs
.map(({ participant, table }) => ({ participant, table, siege: 1, portee: 'tous', tour: null }))
.sort(parTable),
cle,
);
assert.deepEqual(
[...charge.titres].sort(parTable),
charge.tables.map(({ id }) => ({ table: id, siege: 1, libelle: 'animation' })),
cle,
);
assert.deepEqual(
charge.participants
.filter(({ titrePressenti }) => titrePressenti !== null)
.map(({ id, titrePressenti }) => [id, titrePressenti]),
animateurs.map(({ participant }) => [participant, 'animation']).sort((a, b) => a[0] - b[0]),
cle,
);
}
for (const cle of ['petite', 'petite-conflit']) {
const charge = chargeDeDemonstration(entreeDe(cle));
assert.deepEqual([charge.reservations, charge.titres], [[], []], cle);
assert.ok(charge.participants.every(({ titrePressenti }) => titrePressenti === null), cle);
}
});
test("ni proposition ni retenu, personne d'exclu, ni courriel ni notes ; les compteurs dépassent chaque identifiant", () => {
for (const entree of CATALOGUE) {
const charge = chargeDeDemonstration(entree);
assert.deepEqual([charge.propositions, charge.retenu], [[], null], entree.cle);
assert.ok(
charge.participants.every(({ exclu, courriel, notes }) => exclu === false && courriel === null && notes === null),
entree.cle,
);
const plusGrand = (liste) => Math.max(...liste.map(({ id }) => id));
assert.deepEqual(
charge.prochainsIds,
{ participant: plusGrand(charge.participants) + 1, table: plusGrand(charge.tables) + 1, proposition: 1 },
entree.cle,
);
}
});
test("refuse une configuration qu'il ne sait pas écrire : deux réservations à une table, une réservation d'un seul tour", () => {
const avecReservations = (reservations) =>
entreeRetouchee((configuration) => ({ ...configuration, reservations }));
assert.throws(
() =>
chargeDeDemonstration(
avecReservations([
{ participant: 1, table: 2, portee: 'tous' },
{ participant: 5, table: 2, portee: 'tous' },
]),
),
/essai.*table 2/,
);
assert.throws(
() => chargeDeDemonstration(avecReservations([{ participant: 1, table: 3, portee: 'tour', tour: 2 }])),
/essai.*table 3/,
);
});
});
describe("engendrer_livrables : le fichier d'état écrit (§ 8.8, § 18.6)", () => {
test('révision 1, la version de la construction qui écrit, la forme canonique ; UTF-8 sans marque, des LF seules, une fin de ligne finale', () => {
for (const entree of CATALOGUE) {
const texte = texteDeDemonstration(entree);
const lu = analyser(texte);
assert.deepEqual(
[lu.entete.format, lu.entete.produitVersion, lu.entete.revision],
[1, VERSION.affichee, 1],
entree.cle,
);
assert.equal(serialiser(lu.charge, lu.entete), texte, entree.cle);
assert.ok(!texte.startsWith(MARQUE) && !texte.includes('\r'), entree.cle);
assert.ok(texte.endsWith('}\n') && !texte.endsWith('\n\n'), entree.cle);
}
});
test("une autre construction n'écrit qu'un autre en-tête : la charge est la même", () => {
const voisine = versionVoisine(VERSION.affichee);
for (const entree of CATALOGUE) {
const ici = analyser(texteDeDemonstration(entree));
const ailleurs = analyser(texteDeDemonstration(entree, { produitVersion: voisine }));
assert.equal(ailleurs.entete.produitVersion, voisine, entree.cle);
assert.deepEqual(ailleurs.charge, ici.charge, entree.cle);
}
});
});
describe('engendrer_livrables : la variante windows-1252 (§ 10.3)', () => {
test('enWindows1252 : un octet par caractère, les signes propres à windows-1252 compris', () => {
// Octets de la table publiée de windows-1252.
assert.deepEqual(
[...enWindows1252('aé€\u{2019}œŸ\u{A0}ÿ')],
[0x61, 0xe9, 0x80, 0x92, 0x9c, 0x9f, 0xa0, 0xff],
);
const decodeur = new TextDecoder('windows-1252');
for (let octet = 0; octet < 256; octet += 1) {
assert.deepEqual([...enWindows1252(decodeur.decode(Uint8Array.of(octet)))], [octet], `octet ${octet}`);
}
});
test('enWindows1252 refuse un caractère que windows-1252 ne porte pas, et nomme son point de code et son rang', () => {
assert.throws(
() => enWindows1252('noté ✓'),
(erreur) => erreur instanceof RangeError && /U\+2713/.test(erreur.message) && /rang 5/.test(erreur.message),
);
assert.throws(() => enWindows1252('\u{1D11E}'), /U\+1D11E/);
});
test('varianteWindows1252 : les mêmes enregistrements, séparés par des virgules, en windows-1252 sans marque', () => {
const source = [
'Nom;Prénom;Notes',
'Lanterneau;Basile;"chœur; l\u{2019}Anse, nord"',
'Coudraie;Apolline;"deux\nlignes"',
'Brunœil;;sans prénom',
];
const octets = new TextEncoder().encode(`${MARQUE}${source.join('\r\n')}\r\n`);
const variante = varianteWindows1252(octets);
assert.equal(
new TextDecoder('windows-1252').decode(variante),
[
'Nom,Prénom,Notes',
'Lanterneau,Basile,"chœur; l\u{2019}Anse, nord"',
'Coudraie,Apolline,"deux\nlignes"',
'Brunœil,,sans prénom',
'',
].join('\r\n'),
);
assert.deepEqual([...variante.subarray(0, 3)], [...enWindows1252('Nom')]);
assert.deepEqual(
decouper(new TextDecoder('windows-1252').decode(variante), ',').enregistrements,
decouper(new TextDecoder().decode(octets), ';').enregistrements,
);
});
test('varianteWindows1252 refuse un fichier dont un guillemet reste ouvert', () => {
const octets = new TextEncoder().encode(`${MARQUE}Nom;Notes\r\nLanterneau;"sans fin\r\n`);
assert.throws(() => varianteWindows1252(octets), /guillemet/);
});
});
describe('engendrer_livrables : les livrables (§ 10.3, § 15.5)', () => {
test("les sept fichiers, dans l'ordre ; les CSV de démonstration exportent les fichiers d'état écrits, la variante dérive des cas limites", () => {
const casLimites = readFileSync(CAS_LIMITES);
const fichiers = livrables({ casLimites });
assert.deepEqual(
fichiers.map(({ chemin }) => chemin),
LIVRABLES,
);
const octetsDe = new Map(fichiers.map(({ chemin, octets }) => [chemin, octets]));
for (const entree of CATALOGUE) {
assert.equal(
new TextDecoder().decode(octetsDe.get(`src/demo/livrees/${entree.cle}.gtt.json`)),
texteDeDemonstration(entree),
entree.cle,
);
}
for (const cle of ['petite', 'grande']) {
const etat = new TextDecoder().decode(octetsDe.get(`src/demo/livrees/${cle}.gtt.json`));
assert.deepEqual(
octetsDe.get(`exemples/participants_demo_${cle}.csv`),
exporterParticipants(analyser(etat).charge.participants),
cle,
);
}
assert.deepEqual(octetsDe.get('exemples/participants_cas_limites_cp1252.csv'), varianteWindows1252(casLimites));
});
});
describe('engendrer_livrables : la ligne de commande', () => {
test('écrit les sept livrables sous le dossier donné, relatif au répertoire courant, et rien ailleurs, au premier lancement comme au suivant', () =>
avecProjetCopie((dossier, lancerCopie) => {
// Le dossier entier, copie du projet comprise, est photographié avant et
// après chaque lancement : seuls les sept livrables, sous sortie/, y
// apparaissent. Le CSV des cas limites écrit à la main, tout autre
// fichier de la copie et le répertoire courant doivent y rester tels
// quels.
const sept = LIVRABLES.map((chemin) => `sortie/${chemin}`).sort();
const avant = instantane(dossier);
assert.ok(avant.has('projet/exemples/participants_cas_limites.csv'), 'la copie porte le CSV des cas limites');
const premier = lancerCopie('sortie');
assert.equal(premier.status, 0, premier.stderr);
const apres = instantane(dossier);
assert.deepEqual(differences(avant, apres), sept, "le premier lancement n'écrit pas exactement les sept livrables");
const attendus = livrables({ casLimites: readFileSync(CAS_LIMITES) });
for (const { chemin, octets } of attendus) {
assert.deepEqual(new Uint8Array(readFileSync(join(dossier, 'sortie', chemin))), octets, chemin);
assert.match(premier.stdout, new RegExp(`^ *créé +${echapper(chemin)} `, 'm'), chemin);
}
// Relancé, le script ne touche rien hors des sept livrables, qu'il
// réécrit sans en changer un octet : leurs dates peuvent avancer, leurs
// octets non.
const second = lancerCopie('sortie');
assert.equal(second.status, 0, second.stderr);
assert.deepEqual(
differences(apres, instantane(dossier)).filter((chemin) => !sept.includes(chemin)),
[],
'le lancement suivant écrit autre chose que les sept livrables',
);
for (const { chemin, octets } of attendus) {
assert.deepEqual(new Uint8Array(readFileSync(join(dossier, 'sortie', chemin))), octets, chemin);
}
}));
test('relancé, il réécrit les mêmes octets, et le dit', () =>
avecDossier((dossier) => {
const premier = lancer(dossier, '.');
assert.equal(premier.status, 0, premier.stderr);
const octets = LIVRABLES.map((chemin) => readFileSync(join(dossier, chemin)));
const second = lancer(dossier, dossier);
assert.equal(second.status, 0, second.stderr);
LIVRABLES.forEach((chemin, rang) => {
assert.deepEqual(readFileSync(join(dossier, chemin)), octets[rang], chemin);
assert.match(second.stdout, new RegExp(`^ *inchangé +${echapper(chemin)} `, 'm'), chemin);
});
}));
test("une destination qui ne s'écrit pas sort en 1 en nommant la cause", () =>
avecDossier((dossier) => {
writeFileSync(join(dossier, 'occupe'), 'un fichier, pas un dossier\n');
const resultat = lancer(dossier, 'occupe');
assert.equal(resultat.status, 1, resultat.stdout);
assert.match(resultat.stderr, /^Livrables : .*occupe/m);
assert.deepEqual(fichiersSous(dossier), ['occupe']);
}));
test("un argument de trop, ou une option, sort en 2 en rappelant l'usage, sans rien écrire", () =>
avecDossier((dossier) => {
for (const arguments_ of [['a', 'b'], ['--aide']]) {
const resultat = lancer(dossier, ...arguments_);
assert.equal(resultat.status, 2, arguments_.join(' '));
assert.match(resultat.stderr, /usage : node scripts\/engendrer_livrables\.js \[destination\]/);
}
assert.deepEqual(fichiersSous(dossier), []);
}));
});

View file

@ -299,7 +299,14 @@ const LIGNES_OBLIGATOIRES = [
// contrôlent ;
// - les documents (*.md), dont le point 7 contrôle les noms cités ;
// - les épreuves de la dérivation, qui citent des versions par nécessité ;
// - les données d'épreuve, sous test/fixtures/.
// - les données d'épreuve, sous test/fixtures/ ;
// - les démonstrations livrées, sous src/demo/livrees/, données engendrées
// dont l'en-tête porte la version de la construction qui les a écrites
// (§ 18.5, § 18.6) : une livraison suivante ne les réécrit pas (§ 15.5,
// point 5).
// Un répertoire s'exclut par son chemin exact, et rien n'est lu sous lui ;
// un fichier dont le nom commence comme le sien, src/demo/livrees.js, reste
// balayé.
// package.json reste balayé : la forme technique y est à sa place, la forme
// affichée non.
const ARBRES_BALAYES = ['src', 'scripts', 'electron', 'test'];
@ -310,7 +317,7 @@ const FICHIERS_EXCLUS = new Set([
'CHANGELOG.md',
'scripts/version.test.js',
]);
const REPERTOIRES_EXCLUS = new Set(['test/fixtures']);
const REPERTOIRES_EXCLUS = new Set(['test/fixtures', 'src/demo/livrees']);
const DOCUMENT = /\.md$/i;
const ARBRE_DES_SOURCES = 'src/';

View file

@ -629,12 +629,21 @@ describe('version : contrôle du § 18.5, un défaut à la fois', () => {
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 6 : seul le répertoire des démonstrations livrées est exclu, pas un fichier qui porte son nom', () =>
surArbre((racine) => {
ecrire(racine, 'src/demo/livrees.js', "export const v = '2026.10.05.01';\n");
ecrire(racine, 'src/demo/livrees/sous/grande.gtt.json', '{"produitVersion":"2026.10.05.01"}\n');
const echecs = exigerEchecs(racine, [6], [/^6\. src\/demo\/livrees\.js, ligne 1 : « 2026\.10\.05\.01 », forme affichée/]);
assert.equal(echecs.length, 1, echecs.join('\n'));
}));
test('point 6 : données, guides, dépendances et sorties restent hors du périmètre', () =>
surArbre((racine) => {
const versions = `${VERSION} ${TECHNIQUE} ${TECHNIQUE}.0\n`;
for (const chemin of [
'scripts/version.test.js',
'test/fixtures/demonstration.json',
'src/demo/livrees/grande.gtt.json',
'GUIDE-USAGE.md',
'docs/notes.md',
'node_modules/paquet/index.js',

View file

@ -3,8 +3,12 @@
// Garde des noms forgés (§ 14.9, § 15.6) : aucun patronyme forgé complet, nom
// long compris, ni aucune racine d'organisation n'apparaît en mot entier dans
// un fichier texte du projet hors de src/demo/noms.js, qui les définit. Les
// données d'épreuve de test/fixtures/ sont balayées comme le reste.
// un fichier texte du projet hors de src/demo/noms.js, qui les définit, et
// des fichiers que scripts/engendrer_livrables.js engendre et qui les portent
// par construction : les démonstrations livrées, sous src/demo/livrees/, et
// les deux CSV de démonstration, sous exemples/. Le CSV des cas limites,
// écrit à la main, sa variante windows-1252 et les données d'épreuve de
// test/fixtures/ sont balayés comme le reste.
//
// Un mot entier est bordé, de chaque côté, par autre chose qu'une lettre, une
// marque combinante ou un chiffre : le trait de soulignement et le trait
@ -16,11 +20,13 @@
// Chaque fichier de l'arbre exerce une règle : une sonde de test/fixtures/
// porte chaque nom cherché ; d'autres placent un nom au-delà du premier Mio
// du texte, en NFD, après un octet nul, dans une entrée cachée, dans un
// noms.js qui n'est pas celui des listes, ou sous un répertoire exclu ; trois
// derniers se lisent malgré un nom voisin d'une exclusion : src/distances/,
// distinctes.js et un fichier .git. L'épreuve affirme les fichiers lus, le
// compte exact des trouvailles, et que la garde de l'épreuve du projet
// désigne exactement les fichiers placés sous un répertoire exclu.
// noms.js qui n'est pas celui des listes, dans un fichier engendré, ou sous
// un répertoire exclu ; les derniers se lisent malgré un nom voisin d'une
// exclusion : src/distances/, distinctes.js, un fichier .git, un module
// src/demo/livrees.js, le CSV des cas limites et sa variante. L'épreuve
// affirme les fichiers lus, le compte exact des trouvailles, et que la garde
// de l'épreuve du projet désigne exactement les fichiers placés sous un
// répertoire exclu.
//
// L'épreuve établit l'absence dans ce projet, rien sur le monde réel : aucune
// vérification locale ne prouve qu'un nom forgé ne désigne nulle part une
@ -28,7 +34,7 @@
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { NOMS_LONGS, RACINES, patronymes } from './noms.js';
@ -36,6 +42,20 @@ import { NOMS_LONGS, RACINES, patronymes } from './noms.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
const LISTES = join('src', 'demo', 'noms.js');
// Fichiers qui portent les noms forgés par construction, que
// scripts/engendrer_livrables.js écrit (§ 10.3, § 15.6) : tout fichier sous
// src/demo/livrees/, et les deux CSV de démonstration.
const DEMONSTRATIONS_LIVREES = join('src', 'demo', 'livrees') + sep;
const CSV_DE_DEMONSTRATION = new Set([
join('exemples', 'participants_demo_petite.csv'),
join('exemples', 'participants_demo_grande.csv'),
]);
// Vrai pour les listes du générateur et pour un fichier que
// scripts/engendrer_livrables.js en engendre.
const porteLesNomsParConstruction = (chemin) =>
chemin === LISTES || chemin.startsWith(DEMONSTRATIONS_LIVREES) || CSV_DE_DEMONSTRATION.has(chemin);
// Répertoires hors balayage, à toute profondeur : .git, les dépendances, les
// caches des outils, et les sorties de construction, qui portent une copie
// des listes mises en paquet (www, dist et chaque dist-…, plateformes de
@ -87,17 +107,18 @@ function motifDes(noms) {
// Occurrences du motif dans un texte, normalisé en NFC.
const occurrences = (motif, texte) => [...texte.normalize('NFC').matchAll(motif)].map(([mot]) => mot);
// Balaie l'arbre de racine : chaque fichier texte hors des répertoires exclus
// et des listes du générateur se lit en entier, décodé en UTF-8, et chaque
// nom cherché s'y relève. Rend les chemins examinés, relatifs à racine et
// triés, et les trouvailles { chemin, mot }, fichier après fichier, dans
// l'ordre du texte.
// Balaie l'arbre de racine : chaque fichier texte hors des répertoires
// exclus, des listes du générateur et des fichiers que
// scripts/engendrer_livrables.js en engendre se lit en entier, décodé en
// UTF-8, et chaque nom cherché s'y relève. Rend les chemins examinés,
// relatifs à racine et triés, et les trouvailles { chemin, mot }, fichier
// après fichier, dans l'ordre du texte.
function balayer(racine) {
const motif = motifDes(nomsCherches());
const examines = [];
const trouvailles = [];
for (const chemin of fichiers(racine)) {
if (chemin === LISTES) continue;
if (porteLesNomsParConstruction(chemin)) continue;
const octets = readFileSync(join(racine, chemin));
if (estBinaire(octets)) continue;
examines.push(chemin);
@ -178,12 +199,22 @@ describe('noms forgés : balayage du projet (§ 15.6)', () => {
[join('src', 'distances', 'a.js'), patronyme, [patronyme]],
[join('src', 'distinctes.js'), 'paires distinctes\n', []],
[join('sous', '.git'), 'gitdir: ../.git/worktrees/sous\n', []],
// Les voisins des fichiers engendrés se lisent : un module dont le nom
// commence comme leur répertoire, le CSV des cas limites, écrit à la
// main, et sa variante windows-1252, que le script dérive de lui.
[join('src', 'demo', 'livrees.js'), patronyme, [patronyme]],
[join('exemples', 'participants_cas_limites.csv'), patronyme, [patronyme]],
[join('exemples', 'participants_cas_limites_cp1252.csv'), patronyme, [patronyme]],
];
// [chemin, contenu] : les fichiers que le balayage saute — un octet nul
// dans les 8000 premiers, les listes du générateur.
// dans les 8000 premiers, les listes du générateur, les fichiers que le
// script en engendre.
const sautes = [
['nul-dedans.dat', nulA(7999, patronyme)],
[LISTES, patronyme],
[join('src', 'demo', 'livrees', 'grande.gtt.json'), patronyme],
[join('exemples', 'participants_demo_petite.csv'), patronyme],
[join('exemples', 'participants_demo_grande.csv'), patronyme],
];
// Un fichier sous chaque répertoire exclu, à la racine et plus profond ;
// chacun porte un nom forgé.
@ -224,7 +255,7 @@ describe('noms forgés : balayage du projet (§ 15.6)', () => {
assert.deepEqual(arbre.map(([chemin]) => chemin).filter(sousUnRepertoireExclu), exclus);
});
test('aucun patronyme forgé ni aucune racine hors de src/demo/noms.js', () => {
test('aucun patronyme forgé ni aucune racine hors de src/demo/noms.js et des fichiers engendrés', () => {
assert.ok(nomsCherches().length > 0, 'aucun nom forgé à chercher');
const { examines, trouvailles } = balayer(RACINE);
assert.ok(examines.length > 0, 'balayage vide');

57
src/demo/livrees.js Normal file
View file

@ -0,0 +1,57 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les démonstrations livrées (§ 15, § 15.5) : le texte du fichier d'état de
// chaque démonstration du catalogue, tel que scripts/engendrer_livrables.js
// l'a écrit sous livrees/. Le fichier livré est la source de vérité (§ 15.5,
// point 5) : montrer une démonstration lit ce fichier, sans rejouer le
// générateur. Charger une démonstration crée un événement neuf depuis ce
// texte (§ 15) ; le texte, une chaîne, ne se modifie pas, et la
// démonstration d'origine non plus.
//
// Chaque fichier s'importe en module JSON, syntaxe du langage que Node et
// Vite lisent l'un comme l'autre : le module se charge sous Node seul, ce
// qu'exige le repli « node --test » du § 14.8, et un fichier absent casse
// la construction. Un module JSON rend le document, non ses octets : le
// sérialiseur canonique le réécrit sous son propre en-tête. Chaque fichier
// livré est en forme canonique, UTF-8 sans marque, fin de ligne LF ; le
// texte rendu est donc le sien, octet pour octet, ce que livrees.test.js
// exige sous Vitest comme sous Node seul.
//
// Chaque texte décrit l'événement demo:<clé>, à la révision 1, en brouillon,
// sans proposition.
import { serialiser } from '../stockage/canonique.js';
import grande from './livrees/grande.gtt.json' with { type: 'json' };
import grandeSansException from './livrees/grande-sans-exception.gtt.json' with { type: 'json' };
import petite from './livrees/petite.gtt.json' with { type: 'json' };
import petiteConflit from './livrees/petite-conflit.gtt.json' with { type: 'json' };
// Texte canonique d'un fichier d'état lu en module JSON : sa charge, sous la
// révision et la version de son en-tête ; format et comptes se recalculent.
const texteDe = ({ entete, charge }) => serialiser(charge, entete);
/**
* Les quatre démonstrations livrées, dans l'ordre du catalogue
* (catalogue.js) : { cle, texte }, figées ; cle est celle de l'entrée du
* catalogue, texte le fichier d'état livré.
* @type {ReadonlyArray<Readonly<{cle: string, texte: string}>>}
*/
export const LIVREES = Object.freeze([
Object.freeze({ cle: 'grande', texte: texteDe(grande) }),
Object.freeze({ cle: 'grande-sans-exception', texte: texteDe(grandeSansException) }),
Object.freeze({ cle: 'petite', texte: texteDe(petite) }),
Object.freeze({ cle: 'petite-conflit', texte: texteDe(petiteConflit) }),
]);
/**
* Texte livré de la démonstration de clé donnée.
*
* @param {string} cle la clé d'une entrée du catalogue
* @returns {string}
* @throws {RangeError} pour une clé qu'aucune démonstration livrée ne porte
*/
export function texteLivre(cle) {
const livree = LIVREES.find((candidate) => candidate.cle === cle);
if (livree === undefined) throw new RangeError(`aucune démonstration livrée de clé ${JSON.stringify(cle)}`);
return livree.texte;
}

View file

@ -0,0 +1,166 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves longues des démonstrations livrées (§ 14.4) : l'empreinte de la
// charge de chaque fichier livré contre celle que rejoue le script (§ 15.5,
// point 5), le profil d'appartenances lu dans le fichier (§ 15.2), et
// l'aller-retour sur chacun, grande démonstration comprise (§ 8.9). Le
// fichier livré est la source de vérité : un écart d'empreinte se nomme par
// les chemins de la charge qui diffèrent, et se résout en engendrant de
// nouveau, geste conscient, jamais en corrigeant l'épreuve.
import assert from 'node:assert/strict';
import { empreinte, texteDeDemonstration } from '../../scripts/engendrer_livrables.js';
import { describe, test } from '../../test/lanceur.js';
import { versionVoisine } from '../../test/version_voisine.js';
import { serialiser } from '../stockage/canonique.js';
import { difference } from '../stockage/correctifs.js';
import { analyser } from '../stockage/document.js';
import { examiner } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { CATALOGUE } from './catalogue.js';
import { LIVREES, texteLivre } from './livrees.js';
const EMPREINTE = /^[0-9a-f]{64}$/;
// Chemin d'une opération de correctif, lisible : les clés jointes par des
// points, un rang entre crochets, un enregistrement par [id=n].
const cheminLisible = (chemin) =>
chemin
.map((etape) => {
if (typeof etape === 'string') return `.${etape}`;
return typeof etape === 'number' ? `[${etape}]` : `[id=${etape.id}]`;
})
.join('')
.replace(/^\./, '');
// Affirme que la charge rejouée a l'empreinte de la charge livrée ; à
// l'échec, nomme chaque chemin où elles diffèrent, tel que le correctif de
// l'une à l'autre le pose ou le retire (§ 15.5, point 5).
function memeEmpreinte(livree, rejouee, cle) {
if (empreinte(rejouee) === empreinte(livree)) return;
const chemins = difference(livree, rejouee).map(({ op, chemin }) => `${op} ${cheminLisible(chemin)}`);
assert.fail(
`${cle} : la charge que rejoue le script diffère du fichier livré : ${chemins.join(' ; ')}. ` +
'Engendrer de nouveau est un geste conscient : node scripts/engendrer_livrables.js',
);
}
// Profil d'appartenances d'une liste de participants (§ 15.2) : pour chaque
// taille de groupe, le nombre de groupes de cette taille, tailles
// croissantes ; les personnes sans appartenance ne forment pas de groupe.
function profil(participants) {
const tailles = new Map();
for (const { appartenance } of participants) {
if (appartenance !== null) tailles.set(appartenance, (tailles.get(appartenance) ?? 0) + 1);
}
const groupesParTaille = new Map();
for (const taille of tailles.values()) groupesParTaille.set(taille, (groupesParTaille.get(taille) ?? 0) + 1);
return [...groupesParTaille].sort((a, b) => a[0] - b[0]);
}
// Les fichiers livrés, tous ; lève quand il n'y en a aucun (§ 14.2).
function balayer() {
assert.ok(LIVREES.length > 0, 'aucun fichier livré examiné');
return LIVREES;
}
// Aller-retour d'un fichier livré (§ 8.9) : charger — analyser, puis
// contrôler les placements, qui n'écartent ni ne signalent rien —,
// enregistrer sous une autre construction et à la révision suivante,
// recharger. Rend la charge chargée, la charge rechargée, et le texte que
// l'enregistrement écrirait sous l'en-tête lu.
function allerRetour(texte) {
const lu = analyser(texte);
const { charge, fautives, derives } = examiner(lu.charge);
assert.deepEqual([fautives, derives], [[], []]);
const enregistre = serialiser(charge, {
revision: lu.entete.revision + 1,
produitVersion: versionVoisine(VERSION.affichee),
});
const relu = analyser(enregistre);
assert.notEqual(relu.entete.revision, lu.entete.revision);
return { charge, relue: relu.charge, sousLEnteteLu: serialiser(charge, lu.entete) };
}
describe('démonstrations livrées : empreinte (§ 15.5, point 5)', () => {
test("pour chaque entrée du catalogue, la charge que rejoue le script a l'empreinte du fichier livré", () => {
assert.ok(CATALOGUE.length > 0, 'catalogue vide');
for (const entree of CATALOGUE) {
const livree = analyser(texteLivre(entree.cle)).charge;
const rejouee = analyser(texteDeDemonstration(entree)).charge;
memeEmpreinte(livree, rejouee, entree.cle);
}
});
test("un changement de produitVersion seul ne change pas l'empreinte : elle porte sur la charge, l'en-tête exclu", () => {
const voisine = versionVoisine(VERSION.affichee);
for (const entree of CATALOGUE) {
const texteIci = texteDeDemonstration(entree);
const texteAilleurs = texteDeDemonstration(entree, { produitVersion: voisine });
assert.notEqual(texteAilleurs, texteIci, entree.cle);
const ailleurs = analyser(texteAilleurs);
assert.equal(ailleurs.entete.produitVersion, voisine, entree.cle);
assert.equal(empreinte(ailleurs.charge), empreinte(analyser(texteIci).charge), entree.cle);
memeEmpreinte(analyser(texteLivre(entree.cle)).charge, ailleurs.charge, entree.cle);
}
});
test("l'empreinte : 64 chiffres hexadécimaux, une par charge — les quatre démonstrations en ont quatre", () => {
const empreintes = balayer().map(({ texte }) => empreinte(analyser(texte).charge));
assert.ok(empreintes.every((valeur) => EMPREINTE.test(valeur)), empreintes.join(' '));
assert.equal(new Set(empreintes).size, empreintes.length);
});
test("l'échec nomme les chemins qui diffèrent, jamais seulement deux empreintes", () => {
const livree = analyser(texteLivre('petite')).charge;
const rejouee = structuredClone(livree);
rejouee.participants.find(({ id }) => id === 7).nom = 'Lanterneau';
rejouee.tables.find(({ id }) => id === 3).position.x += 1;
assert.notEqual(empreinte(rejouee), empreinte(livree));
assert.throws(
() => memeEmpreinte(livree, rejouee, 'petite'),
(erreur) => {
assert.match(erreur.message, /^petite : /);
assert.match(erreur.message, /poser participants\[id=7\]\.nom ; poser tables\[id=3\]\.position\.x\./);
assert.doesNotMatch(erreur.message, /[0-9a-f]{64}/);
return true;
},
);
});
});
describe("démonstrations livrées : profil d'appartenances (§ 15.2)", () => {
test("les tailles d'appartenance lues dans chaque fichier livré égalent celles du générateur", () => {
for (const entree of CATALOGUE) {
const attendu = profil(analyser(texteLivre(entree.cle)).charge.participants);
assert.ok(attendu.length > 0, entree.cle);
assert.deepEqual(profil(entree.construire().participants), attendu, entree.cle);
}
});
test('le profil compte les groupes par taille, sans les personnes sans appartenance', () => {
const personne = (id, appartenance) => ({ id, appartenance });
assert.deepEqual(
profil([
personne(1, 'Chœur de l\u{2019}Anse'),
personne(2, 'Cercle des Saules'),
personne(3, 'Chœur de l\u{2019}Anse'),
personne(4, null),
personne(5, 'Société des Hérons'),
]),
[[1, 2], [2, 1]],
);
});
});
describe('démonstrations livrées : aller-retour sur chacune, grande comprise (§ 8.9)', () => {
test("charger, enregistrer, recharger rend la même charge ; le texte livré est celui qu'écrit le sérialiseur", () => {
const examines = balayer();
for (const { cle, texte } of examines) {
const { charge, relue, sousLEnteteLu } = allerRetour(texte);
assert.deepEqual(relue, charge, cle);
assert.equal(sousLEnteteLu, texte, cle);
}
assert.ok(examines.some(({ cle }) => cle === 'grande'), 'la grande démonstration non examinée');
});
});

233
src/demo/livrees.test.js Normal file
View file

@ -0,0 +1,233 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des démonstrations livrées et des CSV d'exemple, série surveillée
// (§ 8.9, § 10.3, § 14.4, § 14.8, § 15) : un fichier par entrée du
// catalogue, que LIVREES porte octet pour octet, sous Vitest comme sous Node
// seul ; chacun une démonstration neuve, en forme canonique ;
// l'aller-retour sur la petite démonstration ; les deux CSV de
// démonstration, l'export exact des fichiers livrés ; les deux CSV des cas
// limites, la même lecture en deux encodages, où chaque cas du § 10.3 se
// voit, et des noms inventés hors des listes du générateur. L'empreinte, le
// profil et l'aller-retour sur la grande sont dans livrees.long.test.js.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import { varianteWindows1252 } from '../../scripts/engendrer_livrables.js';
import { describe, test } from '../../test/lanceur.js';
import { versionVoisine } from '../../test/version_voisine.js';
import { apercevoir } from '../csv/apercu.js';
import { decoder } from '../csv/encodage.js';
import { exporterParticipants } from '../csv/export.js';
import { serialiser } from '../stockage/canonique.js';
import { analyser } from '../stockage/document.js';
import { examiner } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { CATALOGUE } from './catalogue.js';
import { LIVREES, texteLivre } from './livrees.js';
import { NOMS_LONGS, PRENOMS, RACINES, TETES, patronymes } from './noms.js';
const DOSSIER_LIVREES = new URL('./livrees/', import.meta.url);
const EXEMPLES = new URL('../../exemples/', import.meta.url);
// Les démonstrations dont la série surveillée fait l'aller-retour ; la série
// longue le fait sur toutes (§ 14.4).
const PETITES = new Set(['petite', 'petite-conflit']);
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
const lireLivre = (cle) => readFileSync(new URL(`${cle}.gtt.json`, DOSSIER_LIVREES));
const lireExemple = (nom) => readFileSync(new URL(nom, EXEMPLES));
// Texte d'un fichier livré, décodé en UTF-8 strict : un octet invalide lève.
const texteDuFichier = (cle) => new TextDecoder('utf-8', { fatal: true }).decode(lireLivre(cle));
// Programme que Node exécute seul, hors de Vite et de Vitest : il importe
// livrees.js et écrit LIVREES en JSON sur sa sortie standard.
const SOUS_NODE_SEUL = [
`const { LIVREES } = await import(${JSON.stringify(new URL('./livrees.js', import.meta.url).href)});`,
'process.stdout.write(JSON.stringify(LIVREES));',
].join('\n');
// Les fichiers livrés que retient garder ; lève quand il n'en reste aucun :
// un balayage qui n'examine rien ne prouve rien (§ 14.2).
function balayer(garder) {
const examines = LIVREES.filter(({ cle }) => garder(cle));
assert.ok(examines.length > 0, 'aucun fichier livré examiné');
return examines;
}
// Aller-retour d'un fichier livré (§ 8.9) : charger — analyser, puis
// contrôler les placements, qui n'écartent ni ne signalent rien —,
// enregistrer sous une autre construction et à la révision suivante,
// recharger. Rend la charge chargée, la charge rechargée, et le texte que
// l'enregistrement écrirait sous l'en-tête lu.
function allerRetour(texte) {
const lu = analyser(texte);
const { charge, fautives, derives } = examiner(lu.charge);
assert.deepEqual([fautives, derives], [[], []]);
const enregistre = serialiser(charge, {
revision: lu.entete.revision + 1,
produitVersion: versionVoisine(VERSION.affichee),
});
const relu = analyser(enregistre);
assert.notEqual(relu.entete.revision, lu.entete.revision);
return { charge, relue: relu.charge, sousLEnteteLu: serialiser(charge, lu.entete) };
}
// Rang du premier octet où deux suites diffèrent, longueur comprise, ou -1.
function premierEcart(a, b) {
const longueur = Math.min(a.length, b.length);
for (let rang = 0; rang < longueur; rang += 1) if (a[rang] !== b[rang]) return rang;
return a.length === b.length ? -1 : longueur;
}
// Les deux lectures du CSV des cas limites : celle du fichier écrit à la
// main et celle de sa variante windows-1252.
function lecturesDesCasLimites() {
return [
['participants_cas_limites.csv', decoder(lireExemple('participants_cas_limites.csv'))],
['participants_cas_limites_cp1252.csv', decoder(lireExemple('participants_cas_limites_cp1252.csv'))],
].map(([nom, { texte, encodage }]) => ({ nom, encodage, apercu: apercevoir(texte) }));
}
describe('démonstrations livrées : les fichiers (§ 15.5, point 5)', () => {
test("un fichier par entrée du catalogue, nommé <clé>.gtt.json, et rien d'autre : ni journal ni proposition", () => {
const fichiers = readdirSync(DOSSIER_LIVREES).sort();
assert.ok(fichiers.length > 0, 'aucun fichier livré');
assert.deepEqual(fichiers, CATALOGUE.map(({ cle }) => `${cle}.gtt.json`).sort());
});
test("LIVREES porte le texte de chaque fichier, octet pour octet, dans l'ordre du catalogue, figé", () => {
assert.deepEqual(
LIVREES.map(({ cle }) => cle),
CATALOGUE.map(({ cle }) => cle),
);
for (const livree of LIVREES) {
assert.equal(
livree.texte,
texteDuFichier(livree.cle),
`${livree.cle} : LIVREES ne porte pas le texte du fichier ; un fichier hors de la forme canonique s'y réécrit autrement`,
);
assert.ok(Object.isFrozen(livree), livree.cle);
}
assert.ok(Object.isFrozen(LIVREES));
});
test('Node seul, sans Vite ni Vitest, importe livrees.js et y lit le texte de chaque fichier : le repli « node --test » le charge (§ 14.8)', () => {
const resultat = spawnSync(process.execPath, ['--input-type=module', '--eval', SOUS_NODE_SEUL], {
encoding: 'utf8',
});
assert.equal(resultat.status, 0, resultat.stderr);
const lues = JSON.parse(resultat.stdout);
assert.deepEqual(
lues.map(({ cle }) => cle),
CATALOGUE.map(({ cle }) => cle),
);
for (const { cle, texte } of lues) {
assert.equal(texte, texteDuFichier(cle), `${cle} : le texte lu sous Node seul diffère du fichier`);
}
});
test("texteLivre rend le texte d'une clé, et refuse une clé qu'aucune démonstration ne porte", () => {
for (const { cle, texte } of LIVREES) assert.equal(texteLivre(cle), texte, cle);
assert.throws(() => texteLivre('moyenne'), (erreur) => erreur instanceof RangeError && /moyenne/.test(erreur.message));
});
test('chaque fichier : UTF-8 sans marque, des LF seules, une seule fin de ligne finale', () => {
for (const { cle } of LIVREES) {
const octets = lireLivre(cle);
assert.notDeepEqual([...octets.subarray(0, 3)], MARQUE_UTF8, cle);
const texte = new TextDecoder('utf-8', { fatal: true }).decode(octets);
assert.ok(!texte.includes('\r'), cle);
assert.ok(texte.endsWith('}\n') && !texte.endsWith('\n\n'), cle);
}
});
test("chaque fichier est une démonstration neuve : identifiant demo:<clé>, nom du catalogue, révision 1, brouillon, sans filiation, ni proposition ni retenu", () => {
for (const { cle, texte } of LIVREES) {
const { entete, charge } = analyser(texte);
assert.deepEqual(
[entete.revision, charge.evenement.id, charge.evenement.nom, charge.evenement.etat, charge.evenement.filiation],
[1, `demo:${cle}`, CATALOGUE.find((entree) => entree.cle === cle).nom, 'brouillon', null],
cle,
);
assert.deepEqual([charge.propositions, charge.retenu], [[], null], cle);
}
});
});
describe('démonstrations livrées : aller-retour sur la petite (§ 8.9, § 14.4)', () => {
test("charger, enregistrer, recharger rend la même charge ; le texte livré est celui qu'écrit le sérialiseur", () => {
for (const { cle, texte } of balayer((cle) => PETITES.has(cle))) {
const { charge, relue, sousLEnteteLu } = allerRetour(texte);
assert.deepEqual(relue, charge, cle);
assert.equal(sousLEnteteLu, texte, cle);
}
});
test("le balayage refuse de passer quand il n'examine aucun fichier (§ 14.2)", () => {
assert.throws(() => balayer(() => false), /aucun fichier livré examiné/);
});
});
describe("CSV d'exemple : les deux de démonstration (§ 10.3)", () => {
test("participants_demo_petite.csv et participants_demo_grande.csv égalent, octet pour octet, l'export des fichiers livrés", () => {
for (const cle of ['petite', 'grande']) {
const lus = lireExemple(`participants_demo_${cle}.csv`);
const attendus = exporterParticipants(analyser(texteLivre(cle)).charge.participants);
assert.equal(premierEcart(lus, attendus), -1, `participants_demo_${cle}.csv : premier octet qui diffère`);
}
});
});
describe("CSV d'exemple : les cas limites (§ 10.3)", () => {
test('UTF-8 avec marque et « ; », puis windows-1252 et « , » : la même lecture, séparateur à part', () => {
assert.deepEqual([...lireExemple('participants_cas_limites.csv').subarray(0, 3)], MARQUE_UTF8);
const [utf8, windows1252] = lecturesDesCasLimites();
assert.deepEqual(
[utf8.encodage, utf8.apercu.separateur, windows1252.encodage, windows1252.apercu.separateur],
['utf-8-bom', ';', 'windows-1252', ','],
);
assert.equal(utf8.apercu.refusGlobal, null);
assert.deepEqual({ ...windows1252.apercu, separateur: ';' }, utf8.apercu);
});
test('la variante windows-1252 est celle que le script dérive du fichier écrit à la main', () => {
const lus = lireExemple('participants_cas_limites_cp1252.csv');
const attendus = varianteWindows1252(lireExemple('participants_cas_limites.csv'));
assert.equal(premierEcart(lus, attendus), -1, 'participants_cas_limites_cp1252.csv : premier octet qui diffère');
});
test('chaque cas du § 10.3 se voit dans les deux fichiers', () => {
for (const { nom, apercu } of lecturesDesCasLimites()) {
const champs = apercu.lignes.map((ligne) => ligne.champs);
const bruts = apercu.lignes.flatMap(({ brut }) => brut);
const cas = [
['une colonne non reconnue', apercu.association.nonReconnues.length > 0],
['une personne sans appartenance', champs.some(({ appartenance }) => appartenance === null)],
['une exclusion', champs.some(({ exclu }) => exclu === true)],
// Un champ qui porte le séparateur n'a pu se lire entier que cité.
['un champ cité contenant le séparateur', bruts.some((champ) => champ.includes(apercu.separateur))],
['un champ cité sur deux lignes', bruts.some((champ) => champ.includes('\n'))],
['un doublon', apercu.doublons.length > 0],
['une personne sans prénom', champs.some(({ prenom }) => prenom === null)],
['deux orthographes fondues', apercu.fusions.some(({ orthographes }) => orthographes.length >= 2)],
['une ligne refusée faute de nom', apercu.refusees.some(({ code }) => code === 'NOM_ABSENT')],
];
assert.deepEqual(cas.filter(([, vu]) => !vu).map(([libelle]) => libelle), [], nom);
}
});
test('noms, organisations et courriels inventés : aucun nom des listes du générateur, chaque courriel sous .test', () => {
const { texte } = decoder(lireExemple('participants_cas_limites.csv'));
// Chaque nom des listes, en mot entier, sans égard à la casse, en NFC.
const noms = [...PRENOMS, ...patronymes(), ...NOMS_LONGS, ...TETES, ...RACINES]
.map((nom) => nom.normalize('NFC').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const motif = new RegExp(`(?<![\\p{L}\\p{M}\\p{N}])(?:${noms.join('|')})(?![\\p{L}\\p{M}\\p{N}])`, 'giu');
assert.deepEqual([...texte.normalize('NFC').matchAll(motif)].map(([mot]) => mot), []);
const courriels = texte.match(/[^\s;,"]+@[^\s;,"]+/g) ?? [];
assert.ok(courriels.length > 0, 'aucun courriel');
assert.deepEqual(courriels.filter((courriel) => !courriel.endsWith('.test')), []);
});
});

View file

@ -0,0 +1,377 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":260,"tables":33,"reservations":33,"titres":33,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"demo:grande-sans-exception","nom":"Grande démonstration sans exception","date":null,"siegesParDefaut":8,"tours":4,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
"prochainsIds": {"participant":261,"table":34,"proposition":1},
"participants": [
{"id":1,"nom":"Mirvault","prenom":"Isabelle","appartenance":"Comptoir Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":2,"nom":"Neskière","prenom":"Youssef","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":3,"nom":"Abrevande","prenom":"Félix","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":4,"nom":"Fralvançon","prenom":"Hugo","appartenance":"Brasserie Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":5,"nom":"Fralveux","prenom":"Manon","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":6,"nom":"Luskotte","prenom":"Catherine","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":7,"nom":"WAMMERWOLM-MAWWEMMEL","prenom":"Zoé","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":8,"nom":"Neskichon","prenom":"Louise","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":9,"nom":"Urvesson","prenom":"Catherine","appartenance":"Fabrique Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":10,"nom":"Hulvivet","prenom":"Karim","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":11,"nom":"Selveron","prenom":"Caroline","appartenance":"Ateliers Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":12,"nom":"Korvault","prenom":"Catherine","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":13,"nom":"Fralvotte","prenom":"Caroline","appartenance":"Fonderie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":14,"nom":"Jusvesson","prenom":"Geneviève","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":15,"nom":"Abrevumet","prenom":"Paul","appartenance":"Ateliers Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":16,"nom":"Fralvault","prenom":"Léa","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":17,"nom":"Obrivet","prenom":"Fatima","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":18,"nom":"Voskeval","prenom":"Philippe","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":19,"nom":"Dulveval","prenom":"Zoé","appartenance":"Menuiserie Quimvaux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":20,"nom":"Korveux","prenom":"Paul","appartenance":"Filature Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":21,"nom":"Luskière","prenom":"Julie","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":22,"nom":"Clovande","prenom":"Émilie","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":23,"nom":"Hulvande","prenom":"Paul","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":24,"nom":"Fralvesson","prenom":"Hugo","appartenance":"Filature Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":25,"nom":"Urvière","prenom":"Isabelle","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":26,"nom":"Rozvotte","prenom":"Thomas","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":27,"nom":"Bralmumet","prenom":"Isabelle","appartenance":"Verrerie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":28,"nom":"Dulvouze","prenom":"Catherine","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":29,"nom":"Voskichon","prenom":"Marc","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":30,"nom":"Mirvivet","prenom":"Simon","appartenance":"Ateliers Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":31,"nom":"Clovault","prenom":"Louise","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":32,"nom":"Voskeux","prenom":"Lucie","appartenance":"Coopérative Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":33,"nom":"Eskignac","prenom":"Amina","appartenance":"Brasserie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":34,"nom":"Korvouze","prenom":"Fatima","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":35,"nom":"Selvoubre","prenom":"Luc","appartenance":"Brasserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":36,"nom":"Fralvichon","prenom":"Étienne","appartenance":"Papeterie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":37,"nom":"Voskivet","prenom":"Philippe","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":38,"nom":"Quilmançon","prenom":"Louise","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":39,"nom":"Luskande","prenom":"Sophie","appartenance":"Fabrique Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":40,"nom":"Abreveron","prenom":"Pierre","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":41,"nom":"Neskeux","prenom":"Philippe","appartenance":"Comptoir Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":42,"nom":"Neskançon","prenom":"Amina","appartenance":"Fabrique Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":43,"nom":"Tolvande","prenom":"Léa","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":44,"nom":"Quilmeron","prenom":"Antoine","appartenance":"Fabrique Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":45,"nom":"Clovumet","prenom":"Guillaume","appartenance":"Verrerie Pravette","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":46,"nom":"Eskeux","prenom":"Michel","appartenance":"Papeterie Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":47,"nom":"Hulvault","prenom":"Louise","appartenance":"Coopérative Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":48,"nom":"Hulveron","prenom":"Catherine","appartenance":"Coopérative Rossolde","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":49,"nom":"Ivrivet","prenom":"François","appartenance":"Menuiserie Sorbagne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":50,"nom":"Eskumet","prenom":"Samuel","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":51,"nom":"Fralvivet","prenom":"Claire","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":52,"nom":"Mirveux","prenom":"Léa","appartenance":"Menuiserie Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":53,"nom":"Gorzesson","prenom":"Karim","appartenance":"Papeterie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":54,"nom":"Fralvumet","prenom":"Jean","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":55,"nom":"Urvançon","prenom":"Manon","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":56,"nom":"Quilmande","prenom":"Sylvie","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":57,"nom":"Gorzichon","prenom":"François","appartenance":"Ateliers Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":58,"nom":"Pruvesson","prenom":"Amina","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":59,"nom":"Quilmière","prenom":"Lucie","appartenance":"Fabrique Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":60,"nom":"Korvesson","prenom":"Fatima","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":61,"nom":"Selvesson","prenom":"Nicolas","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":62,"nom":"Selvivet","prenom":"Louis","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":63,"nom":"Neskesson","prenom":"Louise","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":64,"nom":"Tolvesson","prenom":"Lucie","appartenance":"Filature Zelvaine","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":65,"nom":"Quilmesson","prenom":"Olivier","appartenance":"Comptoir Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":66,"nom":"Luskivet","prenom":"Guillaume","appartenance":"Brasserie Lavorneau","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":67,"nom":"Neskault","prenom":"Gabriel","appartenance":"Menuiserie Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":68,"nom":"Urvumet","prenom":"Antoine","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":69,"nom":"Jusvançon","prenom":"Olivier","appartenance":"Brasserie Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":70,"nom":"Dulvançon","prenom":"Simon","appartenance":"Menuiserie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":71,"nom":"Gorzard","prenom":"Chloé","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":72,"nom":"Pruveux","prenom":"Gabriel","appartenance":"Papeterie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":73,"nom":"Clovière","prenom":"Manon","appartenance":"Ateliers Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":74,"nom":"Obrouze","prenom":"François","appartenance":"Tannerie Olbrecq","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":75,"nom":"Tolveval","prenom":"Karim","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":76,"nom":"Dulvumet","prenom":"Geneviève","appartenance":"Menuiserie Quimvaux","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":77,"nom":"Bérêçàuvïlle-Dœûmôntëÿ","prenom":"Hugo","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":78,"nom":"Gorzeron","prenom":"Claire","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":79,"nom":"Luskumet","prenom":"Hélène","appartenance":"Brasserie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":80,"nom":"Fralvande","prenom":"Louis","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":81,"nom":"Selvault","prenom":"Émilie","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":82,"nom":"Neskumet","prenom":"Léa","appartenance":"Verrerie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":83,"nom":"Korvichon","prenom":"Isabelle","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":84,"nom":"Obrande","prenom":"Michel","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":85,"nom":"Luskeval","prenom":"Isabelle","appartenance":"Brasserie Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":86,"nom":"Tolvoubre","prenom":"Fatima","appartenance":"Papeterie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":87,"nom":"Gorzière","prenom":"Zoé","appartenance":"Menuiserie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":88,"nom":"Korvignac","prenom":"Sylvie","appartenance":"Fabrique Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":89,"nom":"Obreval","prenom":"Antoine","appartenance":"Ateliers Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":90,"nom":"Korveron","prenom":"Michel","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":91,"nom":"Tolvumet","prenom":"Camille","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":92,"nom":"Dulvesson","prenom":"Marie","appartenance":"Ateliers Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":93,"nom":"Jusvard","prenom":"Youssef","appartenance":"Tannerie Aubrenc","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":94,"nom":"Tolvignac","prenom":"Guillaume","appartenance":"Menuiserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":95,"nom":"Mirvotte","prenom":"Gabriel","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":96,"nom":"Urvard","prenom":"Marie","appartenance":"Filature Wassorel","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":97,"nom":"Bralmesson","prenom":"Marie","appartenance":"Coopérative Dravenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":98,"nom":"Neskignac","prenom":"Paul","appartenance":"Brasserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":99,"nom":"Bralmeux","prenom":"Samuel","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":100,"nom":"Ivrotte","prenom":"Raphaël","appartenance":"Coopérative Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":101,"nom":"Luskouze","prenom":"Marc","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":102,"nom":"Jusvotte","prenom":"Sylvie","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":103,"nom":"Bralmoubre","prenom":"Véronique","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":104,"nom":"Selvard","prenom":"Pierre","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":105,"nom":"Hulveux","prenom":"Marie","appartenance":"Coopérative Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":106,"nom":"Pruvotte","prenom":"Sophie","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":107,"nom":"Fralveval","prenom":"Karim","appartenance":"Coopérative Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":108,"nom":"Gorzignac","prenom":"Philippe","appartenance":"Imprimerie Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":109,"nom":"Selvouze","prenom":"Étienne","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":110,"nom":"Hulvichon","prenom":"Louis","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":111,"nom":"Urvoubre","prenom":"Sylvie","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":112,"nom":"Pruveval","prenom":"Pierre","appartenance":"Comptoir Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":113,"nom":"Hulvotte","prenom":"Guillaume","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":114,"nom":"Abrevignac","prenom":"Geneviève","appartenance":"Brasserie Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":115,"nom":"Abrevault","prenom":"Gabriel","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":116,"nom":"Bralmignac","prenom":"Amina","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":117,"nom":"Bralmançon","prenom":"Olivier","appartenance":"Comptoir Lavorneau","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":118,"nom":"Luskoubre","prenom":"Marc","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":119,"nom":"Abrevoubre","prenom":"Geneviève","appartenance":"Menuiserie Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":120,"nom":"Korvoubre","prenom":"Raphaël","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":121,"nom":"Mirvard","prenom":"Pierre","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":122,"nom":"Jusvumet","prenom":"Camille","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":123,"nom":"Cloveval","prenom":"Samuel","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":124,"nom":"Selvière","prenom":"Antoine","appartenance":"Coopérative Dravenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":125,"nom":"Jusvouze","prenom":"Véronique","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":126,"nom":"Neskivet","prenom":"Paul","appartenance":"Comptoir Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":127,"nom":"Dulvande","prenom":"Samuel","appartenance":"Verrerie Pravette","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":128,"nom":"Bralmault","prenom":"Antoine","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":129,"nom":"Abrevière","prenom":"Émilie","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":130,"nom":"Selveval","prenom":"Lucie","appartenance":"Filature Zelvaine","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":131,"nom":"Tolvichon","prenom":"Olivier","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":132,"nom":"Hulvard","prenom":"Fatima","appartenance":"Ateliers Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":133,"nom":"Ivreux","prenom":"Alexandre","appartenance":"Tannerie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":134,"nom":"Korveval","prenom":"Félix","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":135,"nom":"Urvichon","prenom":"Karim","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":136,"nom":"Dulvichon","prenom":"Émilie","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":137,"nom":"Tolvouze","prenom":"Luc","appartenance":"Fabrique Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":138,"nom":"Jusvivet","prenom":"Claire","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":139,"nom":"Quilmault","prenom":"Amina","appartenance":"Coopérative Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":140,"nom":"Ivrouze","prenom":"François","appartenance":"Fonderie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":141,"nom":"Gorzumet","prenom":"Hélène","appartenance":"Fonderie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":142,"nom":"Ivrault","prenom":"Sophie","appartenance":"Coopérative Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":143,"nom":"Bralmière","prenom":"Louise","appartenance":"Tannerie Quimvaux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":144,"nom":"Korvande","prenom":"Nicolas","appartenance":"Comptoir Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":145,"nom":"Ivrumet","prenom":"Geneviève","appartenance":"Coopérative Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":146,"nom":"Obresson","prenom":"Geneviève","appartenance":"Coopérative Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":147,"nom":"Obrançon","prenom":"Guillaume","appartenance":"Papeterie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":148,"nom":"Urvignac","prenom":"Fatima","appartenance":"Ateliers Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":149,"nom":"Bralmotte","prenom":"Claire","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":150,"nom":"Hulvignac","prenom":"Félix","appartenance":"Imprimerie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":151,"nom":"Clovotte","prenom":"François","appartenance":"Coopérative Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":152,"nom":"Dulvoubre","prenom":"Philippe","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":153,"nom":"Rozveron","prenom":"Samuel","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":154,"nom":"Hulvoubre","prenom":"Pierre","appartenance":"Menuiserie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":155,"nom":"Urvouze","prenom":"Émilie","appartenance":"Comptoir Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":156,"nom":"Korvançon","prenom":"Félix","appartenance":"Fonderie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":157,"nom":"Neskande","prenom":"Émilie","appartenance":"Coopérative Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":158,"nom":"Mirvumet","prenom":"Chantal","appartenance":"Menuiserie Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":159,"nom":"Korvotte","prenom":"Alexandre","appartenance":"Menuiserie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":160,"nom":"Eskeval","prenom":"Zoé","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":161,"nom":"Neskard","prenom":"Camille","appartenance":"Menuiserie Gravelune","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":162,"nom":"Tolveron","prenom":"Manon","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":163,"nom":"Voskoubre","prenom":"Nathalie","appartenance":"Fonderie Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":164,"nom":"Pruvière","prenom":"Sophie","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":165,"nom":"Obrichon","prenom":"Paul","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":166,"nom":"Abrevançon","prenom":"Fatima","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":167,"nom":"Eskière","prenom":"Nathalie","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":168,"nom":"Ivreron","prenom":"Caroline","appartenance":"Imprimerie Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":169,"nom":"Neskoubre","prenom":"Véronique","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":170,"nom":"Urvivet","prenom":"Anne","appartenance":"Fonderie Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":171,"nom":"Hulvançon","prenom":"Olivier","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":172,"nom":"Clovançon","prenom":"Karim","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":173,"nom":"Quilmotte","prenom":"Zoé","appartenance":"Ateliers Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":174,"nom":"Eskouze","prenom":"Félix","appartenance":"Filature Zelvaine","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":175,"nom":"Rozvichon","prenom":"Amina","appartenance":"Verrerie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":176,"nom":"Fralvière","prenom":"Simon","appartenance":"Comptoir Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":177,"nom":"Vandergrolcquesmirtavelle-Ostrovaubelindigues","prenom":"Marc","appartenance":"Tannerie Aubrenc","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":178,"nom":"Hulvière","prenom":"Pierre","appartenance":"Papeterie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":179,"nom":"Jusvault","prenom":"Raphaël","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":180,"nom":"Dulvivet","prenom":"Guillaume","appartenance":"Filature Olbrecq","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":181,"nom":"Fralvoubre","prenom":"Pierre","appartenance":"Imprimerie Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":182,"nom":"Selveux","prenom":"Hélène","appartenance":"Brasserie Lavorneau","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":183,"nom":"Rozvançon","prenom":"Caroline","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":184,"nom":"Korvière","prenom":"Olivier","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":185,"nom":"Ivrichon","prenom":"Nicolas","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":186,"nom":"Korvumet","prenom":"Félix","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":187,"nom":"Voskande","prenom":"Félix","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":188,"nom":"Selvumet","prenom":"Guillaume","appartenance":"Papeterie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":189,"nom":"Urvotte","prenom":"Michel","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":190,"nom":"Eskault","prenom":"Émilie","appartenance":"Coopérative Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":191,"nom":"Bralmeval","prenom":"Hélène","appartenance":"Brasserie Dravenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":192,"nom":"Hulvesson","prenom":"Guillaume","appartenance":"Comptoir Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":193,"nom":"Jusvoubre","prenom":"Caroline","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":194,"nom":"Luskeux","prenom":"Félix","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":195,"nom":"Pruvouze","prenom":"Antoine","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":196,"nom":"Quilmignac","prenom":"Julie","appartenance":"Menuiserie Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":197,"nom":"Tolvançon","prenom":"Étienne","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":198,"nom":"Voskumet","prenom":"Hugo","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":199,"nom":"Quilmumet","prenom":"Manon","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":200,"nom":"Voskouze","prenom":"Marc","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":201,"nom":"Abrevard","prenom":"Samuel","appartenance":"Filature Dravenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":202,"nom":"Luskichon","prenom":"Guillaume","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":203,"nom":"Clovesson","prenom":"Manon","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":204,"nom":"Hulvumet","prenom":"Simon","appartenance":"Ateliers Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":205,"nom":"Voskeron","prenom":"Lucie","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":206,"nom":"Selvignac","prenom":"Thomas","appartenance":"Brasserie Sorbagne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":207,"nom":"Neskeron","prenom":"Jean","appartenance":"Filature Zelvaine","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":208,"nom":"Pruvivet","prenom":"Manon","appartenance":"Menuiserie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":209,"nom":"Clovard","prenom":"Sophie","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":210,"nom":"Gorzeval","prenom":"Étienne","appartenance":"Papeterie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":211,"nom":"Ivroubre","prenom":"Philippe","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":212,"nom":"Luskault","prenom":"Gabriel","appartenance":"Verrerie Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":213,"nom":"Korvard","prenom":"Émilie","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":214,"nom":"Quilmichon","prenom":"Thomas","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":215,"nom":"Eskançon","prenom":"Alexandre","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":216,"nom":"Luskançon","prenom":"Émilie","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":217,"nom":"Dulvault","prenom":"Marc","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":218,"nom":"Rozveval","prenom":"Hélène","appartenance":"Comptoir Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":219,"nom":"Pruvançon","prenom":"Julie","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":220,"nom":"Mirveron","prenom":"Louis","appartenance":"Filature Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":221,"nom":"Eskard","prenom":"François","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":222,"nom":"Bralmouze","prenom":"Hugo","appartenance":"Tannerie Falvigne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":223,"nom":"Ivrançon","prenom":"Louis","appartenance":"Coopérative Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":224,"nom":"Clovivet","prenom":"Karim","appartenance":"Fonderie Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":225,"nom":"Rozvande","prenom":"Léa","appartenance":"Verrerie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":226,"nom":"Hulvouze","prenom":"Amina","appartenance":"Coopérative Dravenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":227,"nom":"Eskesson","prenom":"Étienne","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":228,"nom":"Abreveval","prenom":"Philippe","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":229,"nom":"Quilmeux","prenom":"Émilie","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":230,"nom":"Ivreval","prenom":"Gabriel","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":231,"nom":"Voskotte","prenom":"Simon","appartenance":"Fabrique Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":232,"nom":"Obrignac","prenom":"Chantal","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":233,"nom":"Eskotte","prenom":"Guillaume","appartenance":"Verrerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":234,"nom":"Obrard","prenom":"Léa","appartenance":"Comptoir Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":235,"nom":"Gorzivet","prenom":"Guillaume","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":236,"nom":"Mirvançon","prenom":"Geneviève","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":237,"nom":"Selvotte","prenom":"Julie","appartenance":"Comptoir Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":238,"nom":"Mirvesson","prenom":"Amina","appartenance":"Brasserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":239,"nom":"Luskignac","prenom":"Isabelle","appartenance":"Papeterie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":240,"nom":"Obrumet","prenom":"Chloé","appartenance":"Comptoir Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":241,"nom":"Abrevesson","prenom":"Anne","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":242,"nom":"Rozvumet","prenom":"Fatima","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":243,"nom":"Mirvoubre","prenom":"Guillaume","appartenance":"Comptoir Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":244,"nom":"Rozvoubre","prenom":"Véronique","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":245,"nom":"Gorzotte","prenom":"Claire","appartenance":"Menuiserie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":246,"nom":"Ivrière","prenom":"Antoine","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":247,"nom":"Selvichon","prenom":"Isabelle","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":248,"nom":"Urvault","prenom":"Isabelle","appartenance":"Comptoir Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":249,"nom":"Voskesson","prenom":"Julie","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":250,"nom":"Abrevotte","prenom":"Chantal","appartenance":"Ateliers Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":251,"nom":"Neskotte","prenom":"Amina","appartenance":"Fabrique Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":252,"nom":"Voskançon","prenom":"Étienne","appartenance":"Menuiserie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":253,"nom":"Fralveron","prenom":"Zoé","appartenance":"Menuiserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":254,"nom":"Rozvouze","prenom":"Marc","appartenance":"Ateliers Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":255,"nom":"Abreveux","prenom":"Sylvie","appartenance":"Comptoir Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":256,"nom":"Pruvignac","prenom":"Karim","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":257,"nom":"Mirveval","prenom":"Zoé","appartenance":"Fabrique Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":258,"nom":"Fralvignac","prenom":"Philippe","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":259,"nom":"Gorzeux","prenom":"Gabriel","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":260,"nom":"Urveval","prenom":"Jean","appartenance":"Menuiserie Quimvaux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}
],
"tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}},
{"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":1200,"y":0}},
{"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":1500,"y":0}},
{"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
{"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}},
{"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}},
{"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}},
{"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":1200,"y":300}},
{"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":1500,"y":300}},
{"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}},
{"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":300,"y":600}},
{"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":600,"y":600}},
{"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":900,"y":600}},
{"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":1200,"y":600}},
{"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":1500,"y":600}},
{"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}},
{"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":300,"y":900}},
{"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":600,"y":900}},
{"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":900,"y":900}},
{"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":1200,"y":900}},
{"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":1500,"y":900}},
{"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}},
{"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":300,"y":1200}},
{"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":600,"y":1200}},
{"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":900,"y":1200}},
{"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":1200,"y":1200}},
{"id":30,"numero":30,"sieges":null,"forme":"ronde","position":{"x":1500,"y":1200}},
{"id":31,"numero":31,"sieges":null,"forme":"ronde","position":{"x":0,"y":1500}},
{"id":32,"numero":32,"sieges":null,"forme":"ronde","position":{"x":300,"y":1500}},
{"id":33,"numero":33,"sieges":null,"forme":"ronde","position":{"x":600,"y":1500}}
],
"reservations": [
{"participant":2,"table":17,"siege":1,"portee":"tous","tour":null},
{"participant":8,"table":22,"siege":1,"portee":"tous","tour":null},
{"participant":18,"table":6,"siege":1,"portee":"tous","tour":null},
{"participant":34,"table":3,"siege":1,"portee":"tous","tour":null},
{"participant":48,"table":15,"siege":1,"portee":"tous","tour":null},
{"participant":49,"table":32,"siege":1,"portee":"tous","tour":null},
{"participant":54,"table":26,"siege":1,"portee":"tous","tour":null},
{"participant":55,"table":1,"siege":1,"portee":"tous","tour":null},
{"participant":62,"table":25,"siege":1,"portee":"tous","tour":null},
{"participant":74,"table":31,"siege":1,"portee":"tous","tour":null},
{"participant":76,"table":30,"siege":1,"portee":"tous","tour":null},
{"participant":90,"table":8,"siege":1,"portee":"tous","tour":null},
{"participant":93,"table":21,"siege":1,"portee":"tous","tour":null},
{"participant":96,"table":14,"siege":1,"portee":"tous","tour":null},
{"participant":97,"table":27,"siege":1,"portee":"tous","tour":null},
{"participant":99,"table":33,"siege":1,"portee":"tous","tour":null},
{"participant":106,"table":12,"siege":1,"portee":"tous","tour":null},
{"participant":115,"table":24,"siege":1,"portee":"tous","tour":null},
{"participant":128,"table":4,"siege":1,"portee":"tous","tour":null},
{"participant":129,"table":29,"siege":1,"portee":"tous","tour":null},
{"participant":138,"table":20,"siege":1,"portee":"tous","tour":null},
{"participant":161,"table":16,"siege":1,"portee":"tous","tour":null},
{"participant":162,"table":5,"siege":1,"portee":"tous","tour":null},
{"participant":166,"table":13,"siege":1,"portee":"tous","tour":null},
{"participant":177,"table":10,"siege":1,"portee":"tous","tour":null},
{"participant":180,"table":18,"siege":1,"portee":"tous","tour":null},
{"participant":187,"table":7,"siege":1,"portee":"tous","tour":null},
{"participant":197,"table":11,"siege":1,"portee":"tous","tour":null},
{"participant":202,"table":2,"siege":1,"portee":"tous","tour":null},
{"participant":206,"table":28,"siege":1,"portee":"tous","tour":null},
{"participant":211,"table":19,"siege":1,"portee":"tous","tour":null},
{"participant":222,"table":23,"siege":1,"portee":"tous","tour":null},
{"participant":230,"table":9,"siege":1,"portee":"tous","tour":null}
],
"titres": [
{"table":1,"siege":1,"libelle":"animation"},
{"table":2,"siege":1,"libelle":"animation"},
{"table":3,"siege":1,"libelle":"animation"},
{"table":4,"siege":1,"libelle":"animation"},
{"table":5,"siege":1,"libelle":"animation"},
{"table":6,"siege":1,"libelle":"animation"},
{"table":7,"siege":1,"libelle":"animation"},
{"table":8,"siege":1,"libelle":"animation"},
{"table":9,"siege":1,"libelle":"animation"},
{"table":10,"siege":1,"libelle":"animation"},
{"table":11,"siege":1,"libelle":"animation"},
{"table":12,"siege":1,"libelle":"animation"},
{"table":13,"siege":1,"libelle":"animation"},
{"table":14,"siege":1,"libelle":"animation"},
{"table":15,"siege":1,"libelle":"animation"},
{"table":16,"siege":1,"libelle":"animation"},
{"table":17,"siege":1,"libelle":"animation"},
{"table":18,"siege":1,"libelle":"animation"},
{"table":19,"siege":1,"libelle":"animation"},
{"table":20,"siege":1,"libelle":"animation"},
{"table":21,"siege":1,"libelle":"animation"},
{"table":22,"siege":1,"libelle":"animation"},
{"table":23,"siege":1,"libelle":"animation"},
{"table":24,"siege":1,"libelle":"animation"},
{"table":25,"siege":1,"libelle":"animation"},
{"table":26,"siege":1,"libelle":"animation"},
{"table":27,"siege":1,"libelle":"animation"},
{"table":28,"siege":1,"libelle":"animation"},
{"table":29,"siege":1,"libelle":"animation"},
{"table":30,"siege":1,"libelle":"animation"},
{"table":31,"siege":1,"libelle":"animation"},
{"table":32,"siege":1,"libelle":"animation"},
{"table":33,"siege":1,"libelle":"animation"}
],
"propositions": [],
"retenu": null
}
}

View file

@ -0,0 +1,377 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":260,"tables":33,"reservations":33,"titres":33,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"demo:grande","nom":"Grande démonstration","date":null,"siegesParDefaut":8,"tours":4,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
"prochainsIds": {"participant":261,"table":34,"proposition":1},
"participants": [
{"id":1,"nom":"Mirvault","prenom":"Isabelle","appartenance":"Comptoir Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":2,"nom":"Neskière","prenom":"Youssef","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":3,"nom":"Abrevande","prenom":"Félix","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":4,"nom":"Fralvançon","prenom":"Hugo","appartenance":"Brasserie Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":5,"nom":"Fralveux","prenom":"Manon","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":6,"nom":"Luskotte","prenom":"Catherine","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":7,"nom":"WAMMERWOLM-MAWWEMMEL","prenom":"Zoé","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":8,"nom":"Neskichon","prenom":"Louise","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":9,"nom":"Urvesson","prenom":"Catherine","appartenance":"Fabrique Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":10,"nom":"Hulvivet","prenom":"Karim","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":11,"nom":"Selveron","prenom":"Caroline","appartenance":"Ateliers Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":12,"nom":"Korvault","prenom":"Catherine","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":13,"nom":"Fralvotte","prenom":"Caroline","appartenance":"Fonderie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":14,"nom":"Jusvesson","prenom":"Geneviève","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":15,"nom":"Abrevumet","prenom":"Paul","appartenance":"Ateliers Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":16,"nom":"Fralvault","prenom":"Léa","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":17,"nom":"Obrivet","prenom":"Fatima","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":18,"nom":"Voskeval","prenom":"Philippe","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":19,"nom":"Dulveval","prenom":"Zoé","appartenance":"Menuiserie Quimvaux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":20,"nom":"Korveux","prenom":"Paul","appartenance":"Filature Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":21,"nom":"Luskière","prenom":"Julie","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":22,"nom":"Clovande","prenom":"Émilie","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":23,"nom":"Hulvande","prenom":"Paul","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":24,"nom":"Fralvesson","prenom":"Hugo","appartenance":"Filature Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":25,"nom":"Urvière","prenom":"Isabelle","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":26,"nom":"Rozvotte","prenom":"Thomas","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":27,"nom":"Bralmumet","prenom":"Isabelle","appartenance":"Verrerie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":28,"nom":"Dulvouze","prenom":"Catherine","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":29,"nom":"Voskichon","prenom":"Marc","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":30,"nom":"Mirvivet","prenom":"Simon","appartenance":"Ateliers Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":31,"nom":"Clovault","prenom":"Louise","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":32,"nom":"Voskeux","prenom":"Lucie","appartenance":"Coopérative Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":33,"nom":"Eskignac","prenom":"Amina","appartenance":"Brasserie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":34,"nom":"Korvouze","prenom":"Fatima","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":35,"nom":"Selvoubre","prenom":"Luc","appartenance":"Brasserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":36,"nom":"Fralvichon","prenom":"Étienne","appartenance":"Papeterie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":37,"nom":"Voskivet","prenom":"Philippe","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":38,"nom":"Quilmançon","prenom":"Louise","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":39,"nom":"Luskande","prenom":"Sophie","appartenance":"Fabrique Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":40,"nom":"Abreveron","prenom":"Pierre","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":41,"nom":"Neskeux","prenom":"Philippe","appartenance":"Comptoir Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":42,"nom":"Neskançon","prenom":"Amina","appartenance":"Fabrique Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":43,"nom":"Tolvande","prenom":"Léa","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":44,"nom":"Quilmeron","prenom":"Antoine","appartenance":"Fabrique Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":45,"nom":"Clovumet","prenom":"Guillaume","appartenance":"Verrerie Pravette","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":46,"nom":"Eskeux","prenom":"Michel","appartenance":"Papeterie Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":47,"nom":"Hulvault","prenom":"Louise","appartenance":"Coopérative Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":48,"nom":"Hulveron","prenom":"Catherine","appartenance":"Coopérative Rossolde","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":49,"nom":"Ivrivet","prenom":"François","appartenance":"Menuiserie Sorbagne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":50,"nom":"Eskumet","prenom":"Samuel","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":51,"nom":"Fralvivet","prenom":"Claire","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":52,"nom":"Mirveux","prenom":"Léa","appartenance":"Menuiserie Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":53,"nom":"Gorzesson","prenom":"Karim","appartenance":"Papeterie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":54,"nom":"Fralvumet","prenom":"Jean","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":55,"nom":"Urvançon","prenom":"Manon","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":56,"nom":"Quilmande","prenom":"Sylvie","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":57,"nom":"Gorzichon","prenom":"François","appartenance":"Ateliers Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":58,"nom":"Pruvesson","prenom":"Amina","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":59,"nom":"Quilmière","prenom":"Lucie","appartenance":"Fabrique Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":60,"nom":"Korvesson","prenom":"Fatima","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":61,"nom":"Selvesson","prenom":"Nicolas","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":62,"nom":"Selvivet","prenom":"Louis","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":63,"nom":"Neskesson","prenom":"Louise","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":64,"nom":"Tolvesson","prenom":"Lucie","appartenance":"Filature Zelvaine","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":65,"nom":"Quilmesson","prenom":"Olivier","appartenance":"Comptoir Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":66,"nom":"Luskivet","prenom":"Guillaume","appartenance":"Brasserie Lavorneau","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":67,"nom":"Neskault","prenom":"Gabriel","appartenance":"Menuiserie Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":68,"nom":"Urvumet","prenom":"Antoine","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":69,"nom":"Jusvançon","prenom":"Olivier","appartenance":"Brasserie Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":70,"nom":"Dulvançon","prenom":"Simon","appartenance":"Menuiserie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":71,"nom":"Gorzard","prenom":"Chloé","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":72,"nom":"Pruveux","prenom":"Gabriel","appartenance":"Papeterie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":73,"nom":"Clovière","prenom":"Manon","appartenance":"Ateliers Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":74,"nom":"Obrouze","prenom":"François","appartenance":"Tannerie Olbrecq","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":75,"nom":"Tolveval","prenom":"Karim","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":76,"nom":"Dulvumet","prenom":"Geneviève","appartenance":"Menuiserie Quimvaux","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":77,"nom":"Bérêçàuvïlle-Dœûmôntëÿ","prenom":"Hugo","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":78,"nom":"Gorzeron","prenom":"Claire","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":79,"nom":"Luskumet","prenom":"Hélène","appartenance":"Brasserie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":80,"nom":"Fralvande","prenom":"Louis","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":81,"nom":"Selvault","prenom":"Émilie","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":82,"nom":"Neskumet","prenom":"Léa","appartenance":"Verrerie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":83,"nom":"Korvichon","prenom":"Isabelle","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":84,"nom":"Obrande","prenom":"Michel","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":85,"nom":"Luskeval","prenom":"Isabelle","appartenance":"Brasserie Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":86,"nom":"Tolvoubre","prenom":"Fatima","appartenance":"Papeterie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":87,"nom":"Gorzière","prenom":"Zoé","appartenance":"Menuiserie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":88,"nom":"Korvignac","prenom":"Sylvie","appartenance":"Fabrique Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":89,"nom":"Obreval","prenom":"Antoine","appartenance":"Ateliers Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":90,"nom":"Korveron","prenom":"Michel","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":91,"nom":"Tolvumet","prenom":"Camille","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":92,"nom":"Dulvesson","prenom":"Marie","appartenance":"Ateliers Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":93,"nom":"Jusvard","prenom":"Youssef","appartenance":"Tannerie Aubrenc","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":94,"nom":"Tolvignac","prenom":"Guillaume","appartenance":"Menuiserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":95,"nom":"Mirvotte","prenom":"Gabriel","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":96,"nom":"Urvard","prenom":"Marie","appartenance":"Filature Wassorel","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":97,"nom":"Bralmesson","prenom":"Marie","appartenance":"Coopérative Dravenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":98,"nom":"Neskignac","prenom":"Paul","appartenance":"Brasserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":99,"nom":"Bralmeux","prenom":"Samuel","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":100,"nom":"Ivrotte","prenom":"Raphaël","appartenance":"Coopérative Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":101,"nom":"Luskouze","prenom":"Marc","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":102,"nom":"Jusvotte","prenom":"Sylvie","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":103,"nom":"Bralmoubre","prenom":"Véronique","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":104,"nom":"Selvard","prenom":"Pierre","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":105,"nom":"Hulveux","prenom":"Marie","appartenance":"Coopérative Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":106,"nom":"Pruvotte","prenom":"Sophie","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":107,"nom":"Fralveval","prenom":"Karim","appartenance":"Coopérative Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":108,"nom":"Gorzignac","prenom":"Philippe","appartenance":"Imprimerie Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":109,"nom":"Selvouze","prenom":"Étienne","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":110,"nom":"Hulvichon","prenom":"Louis","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":111,"nom":"Urvoubre","prenom":"Sylvie","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":112,"nom":"Pruveval","prenom":"Pierre","appartenance":"Comptoir Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":113,"nom":"Hulvotte","prenom":"Guillaume","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":114,"nom":"Abrevignac","prenom":"Geneviève","appartenance":"Brasserie Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":115,"nom":"Abrevault","prenom":"Gabriel","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":116,"nom":"Bralmignac","prenom":"Amina","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":117,"nom":"Bralmançon","prenom":"Olivier","appartenance":"Comptoir Lavorneau","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":118,"nom":"Luskoubre","prenom":"Marc","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":119,"nom":"Abrevoubre","prenom":"Geneviève","appartenance":"Menuiserie Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":120,"nom":"Korvoubre","prenom":"Raphaël","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":121,"nom":"Mirvard","prenom":"Pierre","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":122,"nom":"Jusvumet","prenom":"Camille","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":123,"nom":"Cloveval","prenom":"Samuel","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":124,"nom":"Selvière","prenom":"Antoine","appartenance":"Coopérative Dravenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":125,"nom":"Jusvouze","prenom":"Véronique","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":126,"nom":"Neskivet","prenom":"Paul","appartenance":"Comptoir Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":127,"nom":"Dulvande","prenom":"Samuel","appartenance":"Verrerie Pravette","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":128,"nom":"Bralmault","prenom":"Antoine","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":129,"nom":"Abrevière","prenom":"Émilie","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":130,"nom":"Selveval","prenom":"Lucie","appartenance":"Filature Zelvaine","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":131,"nom":"Tolvichon","prenom":"Olivier","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":132,"nom":"Hulvard","prenom":"Fatima","appartenance":"Ateliers Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":133,"nom":"Ivreux","prenom":"Alexandre","appartenance":"Tannerie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":134,"nom":"Korveval","prenom":"Félix","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":135,"nom":"Urvichon","prenom":"Karim","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":136,"nom":"Dulvichon","prenom":"Émilie","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":137,"nom":"Tolvouze","prenom":"Luc","appartenance":"Fabrique Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":138,"nom":"Jusvivet","prenom":"Claire","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":139,"nom":"Quilmault","prenom":"Amina","appartenance":"Coopérative Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":140,"nom":"Ivrouze","prenom":"François","appartenance":"Fonderie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":141,"nom":"Gorzumet","prenom":"Hélène","appartenance":"Fonderie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":142,"nom":"Ivrault","prenom":"Sophie","appartenance":"Coopérative Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":143,"nom":"Bralmière","prenom":"Louise","appartenance":"Tannerie Quimvaux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":144,"nom":"Korvande","prenom":"Nicolas","appartenance":"Comptoir Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":145,"nom":"Ivrumet","prenom":"Geneviève","appartenance":"Coopérative Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":146,"nom":"Obresson","prenom":"Geneviève","appartenance":"Coopérative Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":147,"nom":"Obrançon","prenom":"Guillaume","appartenance":"Papeterie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":148,"nom":"Urvignac","prenom":"Fatima","appartenance":"Ateliers Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":149,"nom":"Bralmotte","prenom":"Claire","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":150,"nom":"Hulvignac","prenom":"Félix","appartenance":"Imprimerie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":151,"nom":"Clovotte","prenom":"François","appartenance":"Coopérative Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":152,"nom":"Dulvoubre","prenom":"Philippe","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":153,"nom":"Rozveron","prenom":"Samuel","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":154,"nom":"Hulvoubre","prenom":"Pierre","appartenance":"Menuiserie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":155,"nom":"Urvouze","prenom":"Émilie","appartenance":"Comptoir Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":156,"nom":"Korvançon","prenom":"Félix","appartenance":"Fonderie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":157,"nom":"Neskande","prenom":"Émilie","appartenance":"Coopérative Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":158,"nom":"Mirvumet","prenom":"Chantal","appartenance":"Menuiserie Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":159,"nom":"Korvotte","prenom":"Alexandre","appartenance":"Menuiserie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":160,"nom":"Eskeval","prenom":"Zoé","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":161,"nom":"Neskard","prenom":"Camille","appartenance":"Menuiserie Gravelune","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":162,"nom":"Tolveron","prenom":"Manon","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":163,"nom":"Voskoubre","prenom":"Nathalie","appartenance":"Fonderie Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":164,"nom":"Pruvière","prenom":"Sophie","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":165,"nom":"Obrichon","prenom":"Paul","appartenance":"Ateliers Rossolde","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":166,"nom":"Abrevançon","prenom":"Fatima","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":167,"nom":"Eskière","prenom":"Nathalie","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":168,"nom":"Ivreron","prenom":"Caroline","appartenance":"Imprimerie Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":169,"nom":"Neskoubre","prenom":"Véronique","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":170,"nom":"Urvivet","prenom":"Anne","appartenance":"Fonderie Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":171,"nom":"Hulvançon","prenom":"Olivier","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":172,"nom":"Clovançon","prenom":"Karim","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":173,"nom":"Quilmotte","prenom":"Zoé","appartenance":"Ateliers Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":174,"nom":"Eskouze","prenom":"Félix","appartenance":"Filature Zelvaine","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":175,"nom":"Rozvichon","prenom":"Amina","appartenance":"Verrerie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":176,"nom":"Fralvière","prenom":"Simon","appartenance":"Comptoir Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":177,"nom":"Vandergrolcquesmirtavelle-Ostrovaubelindigues","prenom":"Marc","appartenance":"Tannerie Aubrenc","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":178,"nom":"Hulvière","prenom":"Pierre","appartenance":"Papeterie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":179,"nom":"Jusvault","prenom":"Raphaël","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":180,"nom":"Dulvivet","prenom":"Guillaume","appartenance":"Filature Olbrecq","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":181,"nom":"Fralvoubre","prenom":"Pierre","appartenance":"Imprimerie Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":182,"nom":"Selveux","prenom":"Hélène","appartenance":"Brasserie Lavorneau","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":183,"nom":"Rozvançon","prenom":"Caroline","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":184,"nom":"Korvière","prenom":"Olivier","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":185,"nom":"Ivrichon","prenom":"Nicolas","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":186,"nom":"Korvumet","prenom":"Félix","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":187,"nom":"Voskande","prenom":"Félix","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":188,"nom":"Selvumet","prenom":"Guillaume","appartenance":"Papeterie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":189,"nom":"Urvotte","prenom":"Michel","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":190,"nom":"Eskault","prenom":"Émilie","appartenance":"Coopérative Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":191,"nom":"Bralmeval","prenom":"Hélène","appartenance":"Brasserie Dravenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":192,"nom":"Hulvesson","prenom":"Guillaume","appartenance":"Comptoir Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":193,"nom":"Jusvoubre","prenom":"Caroline","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":194,"nom":"Luskeux","prenom":"Félix","appartenance":"Tannerie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":195,"nom":"Pruvouze","prenom":"Antoine","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":196,"nom":"Quilmignac","prenom":"Julie","appartenance":"Menuiserie Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":197,"nom":"Tolvançon","prenom":"Étienne","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":198,"nom":"Voskumet","prenom":"Hugo","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":199,"nom":"Quilmumet","prenom":"Manon","appartenance":"Fonderie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":200,"nom":"Voskouze","prenom":"Marc","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":201,"nom":"Abrevard","prenom":"Samuel","appartenance":"Filature Dravenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":202,"nom":"Luskichon","prenom":"Guillaume","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":203,"nom":"Clovesson","prenom":"Manon","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":204,"nom":"Hulvumet","prenom":"Simon","appartenance":"Ateliers Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":205,"nom":"Voskeron","prenom":"Lucie","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":206,"nom":"Selvignac","prenom":"Thomas","appartenance":"Brasserie Sorbagne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":207,"nom":"Neskeron","prenom":"Jean","appartenance":"Filature Zelvaine","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":208,"nom":"Pruvivet","prenom":"Manon","appartenance":"Menuiserie Aubrenc","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":209,"nom":"Clovard","prenom":"Sophie","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":210,"nom":"Gorzeval","prenom":"Étienne","appartenance":"Papeterie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":211,"nom":"Ivroubre","prenom":"Philippe","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":212,"nom":"Luskault","prenom":"Gabriel","appartenance":"Verrerie Gravelune","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":213,"nom":"Korvard","prenom":"Émilie","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":214,"nom":"Quilmichon","prenom":"Thomas","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":215,"nom":"Eskançon","prenom":"Alexandre","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":216,"nom":"Luskançon","prenom":"Émilie","appartenance":"Ateliers Tavrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":217,"nom":"Dulvault","prenom":"Marc","appartenance":"Verrerie Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":218,"nom":"Rozveval","prenom":"Hélène","appartenance":"Comptoir Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":219,"nom":"Pruvançon","prenom":"Julie","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":220,"nom":"Mirveron","prenom":"Louis","appartenance":"Filature Wassorel","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":221,"nom":"Eskard","prenom":"François","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":222,"nom":"Bralmouze","prenom":"Hugo","appartenance":"Tannerie Falvigne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":223,"nom":"Ivrançon","prenom":"Louis","appartenance":"Coopérative Morvalle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":224,"nom":"Clovivet","prenom":"Karim","appartenance":"Fonderie Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":225,"nom":"Rozvande","prenom":"Léa","appartenance":"Verrerie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":226,"nom":"Hulvouze","prenom":"Amina","appartenance":"Coopérative Dravenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":227,"nom":"Eskesson","prenom":"Étienne","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":228,"nom":"Abreveval","prenom":"Philippe","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":229,"nom":"Quilmeux","prenom":"Émilie","appartenance":"Ateliers Falvigne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":230,"nom":"Ivreval","prenom":"Gabriel","appartenance":"Verrerie Vaubrenne","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":231,"nom":"Voskotte","prenom":"Simon","appartenance":"Fabrique Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":232,"nom":"Obrignac","prenom":"Chantal","appartenance":"Imprimerie Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":233,"nom":"Eskotte","prenom":"Guillaume","appartenance":"Verrerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":234,"nom":"Obrard","prenom":"Léa","appartenance":"Comptoir Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":235,"nom":"Gorzivet","prenom":"Guillaume","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":236,"nom":"Mirvançon","prenom":"Geneviève","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":237,"nom":"Selvotte","prenom":"Julie","appartenance":"Comptoir Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":238,"nom":"Mirvesson","prenom":"Amina","appartenance":"Brasserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":239,"nom":"Luskignac","prenom":"Isabelle","appartenance":"Papeterie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":240,"nom":"Obrumet","prenom":"Chloé","appartenance":"Comptoir Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":241,"nom":"Abrevesson","prenom":"Anne","appartenance":"Fonderie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":242,"nom":"Rozvumet","prenom":"Fatima","appartenance":"Menuiserie Hostrenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":243,"nom":"Mirvoubre","prenom":"Guillaume","appartenance":"Comptoir Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":244,"nom":"Rozvoubre","prenom":"Véronique","appartenance":"Fonderie Xandrelle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":245,"nom":"Gorzotte","prenom":"Claire","appartenance":"Menuiserie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":246,"nom":"Ivrière","prenom":"Antoine","appartenance":"Fabrique Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":247,"nom":"Selvichon","prenom":"Isabelle","appartenance":"Tannerie Nervanche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":248,"nom":"Urvault","prenom":"Isabelle","appartenance":"Comptoir Yzorne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":249,"nom":"Voskesson","prenom":"Julie","appartenance":"Brasserie Belvarre","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":250,"nom":"Abrevotte","prenom":"Chantal","appartenance":"Ateliers Ivoranche","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":251,"nom":"Neskotte","prenom":"Amina","appartenance":"Fabrique Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":252,"nom":"Voskançon","prenom":"Étienne","appartenance":"Menuiserie Olbrecq","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":253,"nom":"Fralveron","prenom":"Zoé","appartenance":"Menuiserie Sorbagne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":254,"nom":"Rozvouze","prenom":"Marc","appartenance":"Ateliers Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":255,"nom":"Abreveux","prenom":"Sylvie","appartenance":"Comptoir Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":256,"nom":"Pruvignac","prenom":"Karim","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":257,"nom":"Mirveval","prenom":"Zoé","appartenance":"Fabrique Kalvenne","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":258,"nom":"Fralvignac","prenom":"Philippe","appartenance":"Menuiserie Estrolle","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":259,"nom":"Gorzeux","prenom":"Gabriel","appartenance":"Menuiserie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":260,"nom":"Urveval","prenom":"Jean","appartenance":"Menuiserie Quimvaux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}
],
"tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}},
{"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":1200,"y":0}},
{"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":1500,"y":0}},
{"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
{"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}},
{"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}},
{"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}},
{"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":1200,"y":300}},
{"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":1500,"y":300}},
{"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}},
{"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":300,"y":600}},
{"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":600,"y":600}},
{"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":900,"y":600}},
{"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":1200,"y":600}},
{"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":1500,"y":600}},
{"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}},
{"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":300,"y":900}},
{"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":600,"y":900}},
{"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":900,"y":900}},
{"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":1200,"y":900}},
{"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":1500,"y":900}},
{"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}},
{"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":300,"y":1200}},
{"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":600,"y":1200}},
{"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":900,"y":1200}},
{"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":1200,"y":1200}},
{"id":30,"numero":30,"sieges":7,"forme":"ronde","position":{"x":1500,"y":1200}},
{"id":31,"numero":31,"sieges":7,"forme":"ronde","position":{"x":0,"y":1500}},
{"id":32,"numero":32,"sieges":7,"forme":"ronde","position":{"x":300,"y":1500}},
{"id":33,"numero":33,"sieges":7,"forme":"ronde","position":{"x":600,"y":1500}}
],
"reservations": [
{"participant":2,"table":17,"siege":1,"portee":"tous","tour":null},
{"participant":8,"table":22,"siege":1,"portee":"tous","tour":null},
{"participant":18,"table":6,"siege":1,"portee":"tous","tour":null},
{"participant":34,"table":3,"siege":1,"portee":"tous","tour":null},
{"participant":48,"table":15,"siege":1,"portee":"tous","tour":null},
{"participant":49,"table":32,"siege":1,"portee":"tous","tour":null},
{"participant":54,"table":26,"siege":1,"portee":"tous","tour":null},
{"participant":55,"table":1,"siege":1,"portee":"tous","tour":null},
{"participant":62,"table":25,"siege":1,"portee":"tous","tour":null},
{"participant":74,"table":31,"siege":1,"portee":"tous","tour":null},
{"participant":76,"table":30,"siege":1,"portee":"tous","tour":null},
{"participant":90,"table":8,"siege":1,"portee":"tous","tour":null},
{"participant":93,"table":21,"siege":1,"portee":"tous","tour":null},
{"participant":96,"table":14,"siege":1,"portee":"tous","tour":null},
{"participant":97,"table":27,"siege":1,"portee":"tous","tour":null},
{"participant":99,"table":33,"siege":1,"portee":"tous","tour":null},
{"participant":106,"table":12,"siege":1,"portee":"tous","tour":null},
{"participant":115,"table":24,"siege":1,"portee":"tous","tour":null},
{"participant":128,"table":4,"siege":1,"portee":"tous","tour":null},
{"participant":129,"table":29,"siege":1,"portee":"tous","tour":null},
{"participant":138,"table":20,"siege":1,"portee":"tous","tour":null},
{"participant":161,"table":16,"siege":1,"portee":"tous","tour":null},
{"participant":162,"table":5,"siege":1,"portee":"tous","tour":null},
{"participant":166,"table":13,"siege":1,"portee":"tous","tour":null},
{"participant":177,"table":10,"siege":1,"portee":"tous","tour":null},
{"participant":180,"table":18,"siege":1,"portee":"tous","tour":null},
{"participant":187,"table":7,"siege":1,"portee":"tous","tour":null},
{"participant":197,"table":11,"siege":1,"portee":"tous","tour":null},
{"participant":202,"table":2,"siege":1,"portee":"tous","tour":null},
{"participant":206,"table":28,"siege":1,"portee":"tous","tour":null},
{"participant":211,"table":19,"siege":1,"portee":"tous","tour":null},
{"participant":222,"table":23,"siege":1,"portee":"tous","tour":null},
{"participant":230,"table":9,"siege":1,"portee":"tous","tour":null}
],
"titres": [
{"table":1,"siege":1,"libelle":"animation"},
{"table":2,"siege":1,"libelle":"animation"},
{"table":3,"siege":1,"libelle":"animation"},
{"table":4,"siege":1,"libelle":"animation"},
{"table":5,"siege":1,"libelle":"animation"},
{"table":6,"siege":1,"libelle":"animation"},
{"table":7,"siege":1,"libelle":"animation"},
{"table":8,"siege":1,"libelle":"animation"},
{"table":9,"siege":1,"libelle":"animation"},
{"table":10,"siege":1,"libelle":"animation"},
{"table":11,"siege":1,"libelle":"animation"},
{"table":12,"siege":1,"libelle":"animation"},
{"table":13,"siege":1,"libelle":"animation"},
{"table":14,"siege":1,"libelle":"animation"},
{"table":15,"siege":1,"libelle":"animation"},
{"table":16,"siege":1,"libelle":"animation"},
{"table":17,"siege":1,"libelle":"animation"},
{"table":18,"siege":1,"libelle":"animation"},
{"table":19,"siege":1,"libelle":"animation"},
{"table":20,"siege":1,"libelle":"animation"},
{"table":21,"siege":1,"libelle":"animation"},
{"table":22,"siege":1,"libelle":"animation"},
{"table":23,"siege":1,"libelle":"animation"},
{"table":24,"siege":1,"libelle":"animation"},
{"table":25,"siege":1,"libelle":"animation"},
{"table":26,"siege":1,"libelle":"animation"},
{"table":27,"siege":1,"libelle":"animation"},
{"table":28,"siege":1,"libelle":"animation"},
{"table":29,"siege":1,"libelle":"animation"},
{"table":30,"siege":1,"libelle":"animation"},
{"table":31,"siege":1,"libelle":"animation"},
{"table":32,"siege":1,"libelle":"animation"},
{"table":33,"siege":1,"libelle":"animation"}
],
"propositions": [],
"retenu": null
}
}

View file

@ -0,0 +1,32 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"demo:petite-conflit","nom":"Petite démonstration, conflit inévitable","date":null,"siegesParDefaut":3,"tours":4,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
"prochainsIds": {"participant":13,"table":5,"proposition":1},
"participants": [
{"id":1,"nom":"Bralmumet","prenom":"Caroline","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":2,"nom":"Neskault","prenom":"Hugo","appartenance":"Tannerie Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":3,"nom":"Ivrotte","prenom":"Youssef","appartenance":"Verrerie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":4,"nom":"Selvière","prenom":"Louis","appartenance":"Tannerie Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":5,"nom":"Selvançon","prenom":"Youssef","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":6,"nom":"Quilmoubre","prenom":"Amina","appartenance":"Verrerie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":7,"nom":"Voskotte","prenom":"Julie","appartenance":"Verrerie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":8,"nom":"Neskivet","prenom":"Paul","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":9,"nom":"Bralmignac","prenom":"Michel","appartenance":"Tannerie Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":10,"nom":"Tolveval","prenom":"Zoé","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":11,"nom":"Urvard","prenom":"Luc","appartenance":"Tannerie Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":12,"nom":"Mirveval","prenom":"Véronique","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}
],
"tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}
],
"reservations": [],
"titres": [],
"propositions": [],
"retenu": null
}
}

View file

@ -0,0 +1,32 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"demo:petite","nom":"Petite démonstration","date":null,"siegesParDefaut":3,"tours":4,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
"prochainsIds": {"participant":13,"table":5,"proposition":1},
"participants": [
{"id":1,"nom":"Bralmumet","prenom":"Caroline","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":2,"nom":"Neskault","prenom":"Hugo","appartenance":"Tannerie Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":3,"nom":"Ivrotte","prenom":"Youssef","appartenance":"Verrerie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":4,"nom":"Selvière","prenom":"Louis","appartenance":"Tannerie Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":5,"nom":"Selvançon","prenom":"Youssef","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":6,"nom":"Quilmoubre","prenom":"Amina","appartenance":"Verrerie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":7,"nom":"Voskotte","prenom":"Julie","appartenance":"Verrerie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":8,"nom":"Neskivet","prenom":"Paul","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":9,"nom":"Bralmignac","prenom":"Michel","appartenance":"Tannerie Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":10,"nom":"Tolveval","prenom":"Zoé","appartenance":"Verrerie Ulvardes","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":11,"nom":"Urvard","prenom":"Luc","appartenance":"Tannerie Cassorgue","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":12,"nom":"Mirveval","prenom":"Véronique","appartenance":"Ateliers Jolvane","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}
],
"tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}
],
"reservations": [],
"titres": [],
"propositions": [],
"retenu": null
}
}

View file

@ -8,7 +8,10 @@
// un patronyme distinct, chaque appartenance un libellé distinct, sans boucle
// de rattrapage. Les fragments sont forgés pour ne désigner personne ;
// catalogue.long.test.js refuse qu'un patronyme complet ou une racine
// apparaisse ailleurs dans le projet.
// apparaisse ailleurs dans le projet, hors des fichiers que
// scripts/engendrer_livrables.js engendre de ces listes et qui les portent
// par construction : les démonstrations livrées et les deux CSV de
// démonstration (§ 15.6).
//
// L'ordre de chaque liste fait partie du contrat des tirages (§ 15.5,
// point 3) : un tirage désigne un rang, et changer un ordre change les

View file

@ -0,0 +1,441 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Système de fichiers de la plateforme web (§ 13.1, § 13.4) : l'interface de
// systeme_fichiers.js portée par l'OPFS du navigateur, que
// navigator.storage.getDirectory() ouvre. Avec fichiers_electron.js, ce module
// est l'une des deux exceptions nommées de la frontière des couches : il
// touche navigator pour l'OPFS, et document pour le champ de fichier et le
// lien de téléchargement. Il n'y touche qu'à l'appel d'une primitive : le
// charger hors d'un navigateur ne lève rien.
//
// Une seule racine, documents : le dossier gestion_table_tournante_libre de
// l'OPFS, affiché « /gestion_table_tournante_libre ». Ni exécutable publié,
// ni données applicatives, ni racine portable ; le support est inconnu.
//
// La plateforme n'offre ni renommage atomique ni verrou (§ 8.8) et le dit :
// renommageAtomique et verrouDisponible sont faux, et l'application
// l'annonce au démarrage. ecrireAtomique et ajouterLigne écrivent par un flux
// de l'OPFS ouvert sur la cible (createWritable) : le contenu n'atteint la
// cible qu'à la fermeture du flux ; un échec avant elle abandonne le flux et
// retire le fichier que l'appel a créé, si bien que la cible reste telle
// qu'avant l'appel. Rien ne garantit ce que devient une fermeture
// interrompue. deplacer copie les octets de la source sous la cible, puis
// retire la source ; un échec du retrait retire la copie. verrouiller rend
// { pris: true } sans rien écrire, et deverrouiller n'efface rien.
//
// L'OPFS compare les noms à la lettre : la casse et la forme de normalisation
// distinguent deux fichiers, d'où insensibleCasse faux. Il ne date pas un
// dossier, que lister date de 0. Pendant l'écriture d'un flux, Chromium peut
// poser à côté de la cible un fichier d'échange, <nom>.crswap, que lister
// rend comme toute entrée ; un fichier qui disparaît entre la liste et sa
// lecture n'est pas rendu.
//
// L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit
// en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou
// TypeMismatchError pour une entrée d'un autre genre que celui demandé —
// lève ABSENT ; toute autre exception d'une lecture lève LECTURE, car une
// panne n'est pas une absence ; tout échec d'une écriture lève ECRITURE. Les
// deux portent {chemin, dossier, cause}, dont la cause est le nom de
// l'exception : SecurityError pour un OPFS que le navigateur refuse à
// l'origine, NotReadableError pour un fichier qui change pendant qu'on le
// lit, NotFoundError pour une écriture dans un dossier absent,
// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine
// elle-même, un dossier.
//
// Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et
// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter
// passe par un champ de fichier, que le navigateur n'ouvre que dans le
// prolongement d'un geste de l'opérateur, et qui ne dit du fichier choisi
// que son nom : l'échec de sa lecture le nomme, dossier null.
// enregistrerSous passe par un lien de téléchargement : le navigateur range
// le fichier sans dire où ni si l'opérateur y renonce, et la primitive rend
// le nom proposé.
import { ErreurStockage } from './erreurs.js';
import { exigerCheminRelatif } from './systeme_fichiers.js';
// Le dossier du produit dans l'OPFS, et la racine documents qui le désigne.
const DOSSIER_PRODUIT = 'gestion_table_tournante_libre';
const DOCUMENTS = 'documents';
const CHEMIN_DOCUMENTS = `/${DOSSIER_PRODUIT}`;
// Nom du témoin de la sonde (§ 8.6), qui porte aussi ce nom pour texte.
const TEMOIN = '.gtt-temoin';
// Cause d'une écriture qui vise un dossier — la racine elle-même, ou un
// dossier là où un fichier est attendu —, celle que lève l'OPFS.
const TYPE_DIFFERENT = 'TypeMismatchError';
// Cause d'une sonde dont le témoin relu diffère du texte écrit.
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
// retirer celle qu'il porte, que Blob.text retirerait. Un octet qui n'est pas
// de l'UTF-8 se lit U+FFFD.
const UTF8 = new TextEncoder();
const DECODEUR = new TextDecoder('utf-8', { ignoreBOM: true });
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
const ignorer = () => {};
// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou
// d'un autre genre que celui demandé.
const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT;
// Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son
// texte quand elle n'en porte pas.
const causeDe = (erreur) => (typeof erreur?.name === 'string' && erreur.name !== '' ? erreur.name : String(erreur));
function exigerChaine(valeur, quoi) {
if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`);
}
function exigerSeance(seance) {
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
}
// Ce que désigne un chemin relatif à la racine : ses segments, ceux du
// dossier qui le porte, son nom — null pour '', la racine elle-même —, et le
// chemin affichable de ce dossier, celui de la racine pour ''. CHEMIN_REFUSE
// pour un chemin refusé ou une racine inconnue, reconnue par son seul
// identifiant.
function localiser(racine, chemin) {
const segments = exigerCheminRelatif(chemin);
if (racine?.id !== DOCUMENTS) throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
const parent = segments.slice(0, -1);
return {
chemin,
segments,
parent,
nom: segments.length > 0 ? segments[segments.length - 1] : null,
dossier: [CHEMIN_DOCUMENTS, ...parent].join('/'),
};
}
const refus = (loc, cause) => new ErreurStockage('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause });
const absent = (chemin) => new ErreurStockage('ABSENT', { chemin });
// Ce que devient l'exception d'une lecture de loc : ABSENT quand l'entrée
// manque, LECTURE sinon.
const echecLecture = (loc, erreur) =>
estAbsence(erreur)
? absent(loc.chemin)
: new ErreurStockage('LECTURE', { chemin: loc.chemin, dossier: loc.dossier, cause: causeDe(erreur) });
// Exécute travail, une écriture sur loc, et rend ce qu'il rend : une
// ErreurStockage passe telle quelle, toute autre exception devient ECRITURE.
async function ecrire(loc, travail) {
try {
return await travail();
} catch (erreur) {
if (erreur instanceof ErreurStockage) throw erreur;
throw refus(loc, causeDe(erreur));
}
}
// Poignée du dossier que désignent les segments sous le dossier du produit ;
// creer crée ce qui manque, dossier du produit compris. Lève l'exception de
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
// fichier à sa place.
async function dossierDe(segments, creer = false) {
const options = { create: creer };
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, options);
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options);
return dossier;
}
// Le dossier que désignent les segments, ou null quand il n'existe pas.
async function dossierExistant(segments) {
try {
return await dossierDe(segments);
} catch (erreur) {
if (estAbsence(erreur)) return null;
throw erreur;
}
}
// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle
// n'existe pas.
async function genreDe(dossier, nom) {
try {
await dossier.getFileHandle(nom);
return 'fichier';
} catch (erreur) {
if (erreur?.name === TYPE_DIFFERENT) return 'dossier';
if (erreur?.name === 'NotFoundError') return null;
throw erreur;
}
}
// Retire l'entrée nom du dossier ; une entrée absente n'est pas une faute.
async function retirer(dossier, nom) {
try {
await dossier.removeEntry(nom);
} catch (erreur) {
if (erreur?.name !== 'NotFoundError') throw erreur;
}
}
// Texte d'un fichier : ses octets décodés tels quels.
const lire = async (poignee) => DECODEUR.decode(await (await poignee.getFile()).arrayBuffer());
// Écrit le fichier nom du dossier par un flux de l'OPFS, que remplir(flux,
// poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur
// un contenu vide. Le contenu n'atteint le fichier qu'à la fermeture du flux.
// Un échec abandonne le flux, retire le fichier quand l'appel l'a créé, puis
// se relance : le fichier reste tel qu'avant l'appel.
async function ecrireParFlux(dossier, nom, garder, remplir) {
let poignee;
let cree = false;
try {
poignee = await dossier.getFileHandle(nom);
} catch (erreur) {
if (erreur?.name !== 'NotFoundError') throw erreur;
poignee = await dossier.getFileHandle(nom, { create: true });
cree = true;
}
let flux = null;
try {
flux = await poignee.createWritable({ keepExistingData: garder });
await remplir(flux, poignee);
await flux.close();
} catch (erreur) {
if (flux !== null) await flux.abort().catch(ignorer);
if (cree) await dossier.removeEntry(nom).catch(ignorer);
throw erreur;
}
}
// Description d'une entrée d'un dossier, ou null pour un fichier disparu
// entre la liste et sa lecture.
async function decrire(poignee) {
if (poignee.kind === 'directory') return { nom: poignee.name, type: 'dossier', taille: 0, modifie: 0 };
try {
const fichier = await poignee.getFile();
return { nom: poignee.name, type: 'fichier', taille: fichier.size, modifie: fichier.lastModified };
} catch (erreur) {
if (erreur?.name === 'NotFoundError') return null;
throw erreur;
}
}
// Ordre des entrées : leurs noms comparés par unités UTF-16, une à une.
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
// La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier.
async function lireSource(loc) {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const dossier = await dossierExistant(loc.parent);
const genre = dossier === null ? null : await genreDe(dossier, loc.nom);
if (genre === null) throw absent(loc.chemin);
if (genre === 'dossier') throw refus(loc, TYPE_DIFFERENT);
const fichier = await (await dossier.getFileHandle(loc.nom)).getFile();
return { dossier, octets: await fichier.arrayBuffer() };
}
// Écrit les octets sous la cible de deplacer, et rend le dossier qui la
// porte. EXISTE quand une entrée y porte déjà ce nom, quel qu'en soit le
// genre, et pour '', la racine elle-même.
async function copierVers(loc, octets) {
if (loc.nom === null) throw new ErreurStockage('EXISTE', { chemin: loc.chemin });
const dossier = await dossierDe(loc.parent);
if ((await genreDe(dossier, loc.nom)) !== null) throw new ErreurStockage('EXISTE', { chemin: loc.chemin });
await ecrireParFlux(dossier, loc.nom, false, (flux) => flux.write(octets));
return dossier;
}
// Ouvre le dialogue du navigateur par un champ de fichier, hors du document,
// et rend le nom et les octets du fichier choisi ; null quand l'opérateur
// annule — l'événement cancel — ou que le champ revient sans fichier. Un
// fichier choisi qui ne se lit pas lève ABSENT ou LECTURE sous son nom.
function choisirFichier() {
return new Promise((resoudre, rejeter) => {
const champ = document.createElement('input');
champ.type = 'file';
champ.addEventListener('cancel', () => resoudre(null), { once: true });
champ.addEventListener(
'change',
() => {
const fichier = champ.files[0];
if (fichier === undefined) {
resoudre(null);
return;
}
fichier.arrayBuffer().then(
(tampon) => resoudre({ nom: fichier.name, octets: new Uint8Array(tampon) }),
(erreur) => rejeter(echecLecture({ chemin: fichier.name, dossier: null }, erreur)),
);
},
{ once: true },
);
champ.click();
});
}
/**
* Système de fichiers de la plateforme web, sur l'OPFS. Chaque appel rend un
* système neuf ; tous partagent l'OPFS de l'origine.
*
* @returns {import('./systeme_fichiers.js').SystemeFichiers}
*/
export function creerFichiersWeb() {
return {
nature: 'web',
renommageAtomique: false,
verrouDisponible: false,
emplacements: async () => ({ executable: null, donneesApplicatives: [], insensibleCasse: false, separateur: '/' }),
racines: async () => ({ portable: null, documents: { id: DOCUMENTS, chemin: CHEMIN_DOCUMENTS } }),
choisirDossier: async () => {
throw new ErreurStockage('NON_DISPONIBLE', { primitive: 'choisirDossier' });
},
// Crée le dossier du produit, efface un témoin resté, écrit le témoin, le
// relit, l'efface. Un échec rend sa cause et efface le témoin qu'il
// laisse ; seule une racine inconnue lève.
sonder: async (racine) => {
localiser(racine, '');
let dossier = null;
try {
dossier = await dossierDe([], true);
await retirer(dossier, TEMOIN);
await ecrireParFlux(dossier, TEMOIN, false, (flux) => flux.write(UTF8.encode(TEMOIN)));
const relu = await lire(await dossier.getFileHandle(TEMOIN));
await retirer(dossier, TEMOIN);
return relu === TEMOIN
? { inscriptible: true, cause: null }
: { inscriptible: false, cause: RELECTURE_DIFFERENTE };
} catch (erreur) {
if (dossier !== null) await retirer(dossier, TEMOIN).catch(ignorer);
return { inscriptible: false, cause: causeDe(erreur) };
}
},
typeSupport: async (racine) => {
localiser(racine, '');
return 'inconnu';
},
lireTexte: async (racine, chemin) => {
const loc = localiser(racine, chemin);
if (loc.nom === null) throw absent(chemin);
try {
return await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
} catch (erreur) {
throw echecLecture(loc, erreur);
}
},
ecrireAtomique: async (racine, chemin, texte) => {
exigerChaine(texte, 'le texte');
const loc = localiser(racine, chemin);
await ecrire(loc, async () => {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const octets = UTF8.encode(texte);
await ecrireParFlux(await dossierDe(loc.parent), loc.nom, false, (flux) => flux.write(octets));
});
},
// Le flux garde le contenu présent et écrit la ligne à sa fin.
ajouterLigne: async (racine, chemin, ligne) => {
exigerChaine(ligne, 'la ligne');
if (/[\r\n]/.test(ligne)) throw new TypeError('la ligne porte une fin de ligne');
const loc = localiser(racine, chemin);
await ecrire(loc, async () => {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const octets = UTF8.encode(`${ligne}\n`);
await ecrireParFlux(await dossierDe(loc.parent), loc.nom, true, async (flux, poignee) => {
const { size } = await poignee.getFile();
await flux.write({ type: 'write', position: size, data: octets });
});
});
},
lister: async (racine, chemin) => {
const loc = localiser(racine, chemin);
const entrees = [];
try {
for await (const poignee of (await dossierDe(loc.segments)).values()) {
const entree = await decrire(poignee);
if (entree !== null) entrees.push(entree);
}
} catch (erreur) {
throw echecLecture(loc, erreur);
}
return entrees.sort(parNom);
},
creerDossier: async (racine, chemin) => {
const loc = localiser(racine, chemin);
await ecrire(loc, () => dossierDe(loc.segments, true));
},
// Un échec de la lecture de la source ou de son retrait nomme la source ;
// un échec de la copie nomme la cible.
deplacer: async (racine, de, vers) => {
const source = localiser(racine, de);
const cible = localiser(racine, vers);
const { dossier: dossierSource, octets } = await ecrire(source, () => lireSource(source));
const dossierCible = await ecrire(cible, () => copierVers(cible, octets));
await ecrire(source, async () => {
try {
await dossierSource.removeEntry(source.nom);
} catch (erreur) {
await dossierCible.removeEntry(cible.nom).catch(ignorer);
throw erreur;
}
});
},
// Retire un fichier ; un dossier, la racine comprise, lève ECRITURE
// TypeMismatchError et reste.
supprimer: async (racine, chemin) => {
const loc = localiser(racine, chemin);
await ecrire(loc, async () => {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const dossier = await dossierExistant(loc.parent);
const genre = dossier === null ? null : await genreDe(dossier, loc.nom);
if (genre === 'dossier') throw refus(loc, TYPE_DIFFERENT);
if (genre === 'fichier') await retirer(dossier, loc.nom);
});
},
verrouiller: async (racine, chemin, seance) => {
exigerSeance(seance);
localiser(racine, chemin);
return { pris: true };
},
deverrouiller: async (racine, chemin, seance) => {
exigerSeance(seance);
localiser(racine, chemin);
},
ouvrirDansExplorateur: async (racine) => {
localiser(racine, '');
throw new ErreurStockage('NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' });
},
choisirFichierAImporter: () => choisirFichier(),
// Le clic remet au navigateur l'adresse du contenu, qui se libère à la
// tâche suivante.
enregistrerSous: async (nomPropose, octets) => {
if (typeof nomPropose !== 'string' || nomPropose === '') {
throw new TypeError("le nom proposé n'est pas une chaîne non vide");
}
if (!(octets instanceof Uint8Array)) throw new TypeError("les octets ne sont pas un Uint8Array");
const adresse = URL.createObjectURL(new Blob([octets], { type: 'application/octet-stream' }));
const lien = document.createElement('a');
lien.href = adresse;
lien.download = nomPropose;
lien.click();
setTimeout(() => URL.revokeObjectURL(adresse), 0);
return nomPropose;
},
};
}

View file

@ -0,0 +1,498 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du système de fichiers de la plateforme web (§ 8.8, § 13.1,
// § 13.4), dans Chromium, sur son OPFS réel, vidé avant chaque épreuve. La
// suite de contrat test/contrat_fichiers.js s'y joue avec expect ; suit ce
// qui n'appartient qu'à la plateforme web : sa nature, ses emplacements, sa
// racine unique — le dossier gestion_table_tournante_libre de l'OPFS —, la
// correspondance des refus de l'OPFS avec les codes du stockage, le verrou
// qui n'en est pas un, les primitives qui n'existent pas sous web, le choix
// d'un fichier à importer et l'enregistrement par téléchargement.
//
// Une panne de l'OPFS se provoque en remplaçant, le temps d'une épreuve, une
// méthode de ses prototypes (vi.spyOn), que afterEach rétablit ; un dialogue,
// en remplaçant le clic du champ de fichier ou du lien. Les mains de
// l'épreuve sur l'OPFS passent par navigator.storage, jamais par le module
// éprouvé. Le projet navigateur n'importe pas node:assert/strict : il emploie
// expect, et prend test, describe et les crochets chez Vitest. Les noms
// d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { eprouverContrat } from '../../test/contrat_fichiers.js';
import { ErreurStockage } from './erreurs.js';
import { creerFichiersWeb } from './fichiers_web.js';
// Le dossier du produit dans l'OPFS, et la racine qui le désigne.
const DOSSIER = 'gestion_table_tournante_libre';
const CHEMIN_DOCUMENTS = '/gestion_table_tournante_libre';
const DOCUMENTS = { id: 'documents', chemin: CHEMIN_DOCUMENTS };
// Octets d'un fichier venu d'ailleurs : marque d'ordre d'octets, CRLF, et un
// octet qui n'est pas de l'UTF-8.
const BRUTS = new Uint8Array([0xef, 0xbb, 0xbf, 0x6e, 0x6f, 0x6d, 0x3b, 0x0d, 0x0a, 0xff]);
const opfs = () => navigator.storage.getDirectory();
// Retire toute entrée de la racine de l'OPFS.
async function viderOpfs() {
const racine = await opfs();
const noms = [];
for await (const nom of racine.keys()) noms.push(nom);
for (const nom of noms) await racine.removeEntry(nom, { recursive: true });
}
// Noms des entrées de la racine de l'OPFS, triés.
async function nomsOpfs() {
const noms = [];
for await (const nom of (await opfs()).keys()) noms.push(nom);
return noms.sort();
}
// Le dossier du produit, lu dans l'OPFS ; NotFoundError quand il manque.
const dossierProduit = async () => (await opfs()).getDirectoryHandle(DOSSIER);
// Octets du fichier que désignent les segments sous le dossier du produit.
async function octetsOpfs(...segments) {
let dossier = await dossierProduit();
for (const segment of segments.slice(0, -1)) dossier = await dossier.getDirectoryHandle(segment);
const fichier = await (await dossier.getFileHandle(segments.at(-1))).getFile();
return new Uint8Array(await fichier.arrayBuffer());
}
// Pose des octets sous un nom, à la racine du dossier du produit.
async function poserOpfs(nom, octets) {
const flux = await (await (await dossierProduit()).getFileHandle(nom, { create: true })).createWritable();
await flux.write(octets);
await flux.close();
}
// Un système neuf sur un dossier du produit qui existe et ne porte rien.
async function preparer() {
await (await opfs()).getDirectoryHandle(DOSSIER, { create: true });
return creerFichiersWeb();
}
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
async function rejette(promesse, message) {
try {
await promesse;
} catch (raison) {
return raison;
}
return expect.unreachable(`${message} : la promesse se résout`);
}
// Attend le rejet d'une ErreurStockage de ce code et de ces détails.
async function echoue(promesse, code, details, message = code) {
const erreur = await rejette(promesse, message);
expect(erreur, message).toBeInstanceOf(ErreurStockage);
expect(erreur.code, message).toBe(code);
expect(erreur.details, message).toStrictEqual(details);
}
const noms = (entrees) => entrees.map((entree) => entree.nom);
beforeEach(viderOpfs);
afterEach(() => {
vi.restoreAllMocks();
});
eprouverContrat("web, sur l'OPFS de Chromium", async () => {
const fs = await preparer();
return { fs, racine: (await fs.racines()).documents };
}, {
describe,
test,
egal: (reel, attendu, message) => expect(reel, message).toStrictEqual(attendu),
vrai: (valeur, message) => expect(valeur, message).toBeTruthy(),
rejette,
});
describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
test("nature web, ni renommage atomique ni verrou ; ni exécutable ni données applicatives ; noms comparés à la lettre sous « / » ; support inconnu", async () => {
const fs = creerFichiersWeb();
expect(fs.nature).toBe('web');
expect(fs.renommageAtomique).toBe(false);
expect(fs.verrouDisponible).toBe(false);
expect(await fs.emplacements()).toStrictEqual({
executable: null,
donneesApplicatives: [],
insensibleCasse: false,
separateur: '/',
});
expect(await fs.racines()).toStrictEqual({ portable: null, documents: DOCUMENTS });
expect(await fs.typeSupport(DOCUMENTS)).toBe('inconnu');
});
test("la racine documents est le dossier gestion_table_tournante_libre de l'OPFS : ce qui s'y écrit s'y lit en UTF-8 sans marque d'ordre d'octets, et ce qui s'y pose se lit et se liste", async () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
await fs.ecrireAtomique(DOCUMENTS, 'corbeille/\u{E9}t\u{E9}.txt', '\u{E9}t\u{E9}');
await fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', '\u{AB} a \u{BB}');
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
expect(await octetsOpfs('corbeille', '\u{E9}t\u{E9}.txt')).toStrictEqual(
new Uint8Array([0xc3, 0xa9, 0x74, 0xc3, 0xa9]),
);
expect(await octetsOpfs('journal.jsonl')).toStrictEqual(
new Uint8Array([0xc2, 0xab, 0x20, 0x61, 0x20, 0xc2, 0xbb, 0x0a]),
);
// Un octet qui n'est pas de l'UTF-8 se lit U+FFFD ; la marque d'ordre
// d'octets et CRLF restent.
await poserOpfs('pose.csv', BRUTS);
expect(await fs.lireTexte(DOCUMENTS, 'pose.csv')).toBe('\u{FEFF}nom;\r\n\u{FFFD}');
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille', 'journal.jsonl', 'pose.csv']);
});
test("sonder crée le dossier du produit dans un OPFS vide, et n'y laisse aucun témoin", async () => {
const fs = creerFichiersWeb();
expect(await nomsOpfs()).toStrictEqual([]);
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: true, cause: null });
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
});
test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien ; creerDossier le crée", async () => {
const fs = creerFichiersWeb();
const refus = (chemin) => ({ chemin, dossier: CHEMIN_DOCUMENTS, cause: 'NotFoundError' });
await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'x'), 'ECRITURE', refus('etat.json'));
await echoue(fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'x'), 'ECRITURE', refus('journal.jsonl'));
await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'ABSENT', { chemin: 'etat.json' });
await echoue(fs.lister(DOCUMENTS, ''), 'ABSENT', { chemin: '' });
await echoue(fs.deplacer(DOCUMENTS, 'etat.json', 'copie.json'), 'ABSENT', { chemin: 'etat.json' });
await fs.supprimer(DOCUMENTS, 'etat.json');
expect(await nomsOpfs()).toStrictEqual([]);
await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05');
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
expect(noms(await fs.lister(DOCUMENTS, 'corbeille'))).toStrictEqual(['2026-01-02_03-04-05']);
});
});
describe("fichiers web : ce que l'OPFS refuse", () => {
test("une entrée d'un autre genre que celui demandé : lire ou lister lèvent ABSENT, écrire ECRITURE TypeMismatchError, qui nomme le dossier affichable de l'entrée", async () => {
const fs = await preparer();
await fs.ecrireAtomique(DOCUMENTS, 'note.txt', 'note');
await fs.creerDossier(DOCUMENTS, 'corbeille');
const refus = (chemin, dossier) => ({ chemin, dossier, cause: 'TypeMismatchError' });
// Un dossier là où un fichier est attendu, et la racine elle-même.
await echoue(fs.lireTexte(DOCUMENTS, 'corbeille'), 'ABSENT', { chemin: 'corbeille' });
await echoue(fs.lireTexte(DOCUMENTS, ''), 'ABSENT', { chemin: '' });
await echoue(fs.ecrireAtomique(DOCUMENTS, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS));
await echoue(fs.ecrireAtomique(DOCUMENTS, '', 'x'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS));
await echoue(fs.ajouterLigne(DOCUMENTS, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS));
await echoue(fs.ajouterLigne(DOCUMENTS, '', 'x'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS));
await echoue(fs.supprimer(DOCUMENTS, 'corbeille'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS));
await echoue(fs.supprimer(DOCUMENTS, ''), 'ECRITURE', refus('', CHEMIN_DOCUMENTS));
await echoue(fs.deplacer(DOCUMENTS, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS));
await echoue(fs.deplacer(DOCUMENTS, '', 'ailleurs'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS));
await echoue(fs.deplacer(DOCUMENTS, 'note.txt', 'corbeille'), 'EXISTE', { chemin: 'corbeille' });
await echoue(fs.deplacer(DOCUMENTS, 'note.txt', ''), 'EXISTE', { chemin: '' });
// Un fichier là où un dossier est attendu.
const sousNote = `${CHEMIN_DOCUMENTS}/note.txt`;
await echoue(fs.lister(DOCUMENTS, 'note.txt'), 'ABSENT', { chemin: 'note.txt' });
await echoue(fs.lireTexte(DOCUMENTS, 'note.txt/x.txt'), 'ABSENT', { chemin: 'note.txt/x.txt' });
await echoue(fs.deplacer(DOCUMENTS, 'note.txt/x.txt', 'x.txt'), 'ABSENT', { chemin: 'note.txt/x.txt' });
await echoue(fs.ecrireAtomique(DOCUMENTS, 'note.txt/x.txt', 'x'), 'ECRITURE', refus('note.txt/x.txt', sousNote));
await echoue(fs.ajouterLigne(DOCUMENTS, 'note.txt/x.txt', 'x'), 'ECRITURE', refus('note.txt/x.txt', sousNote));
await echoue(fs.creerDossier(DOCUMENTS, 'note.txt/sous'), 'ECRITURE', refus('note.txt/sous', sousNote));
await echoue(fs.deplacer(DOCUMENTS, 'note.txt', 'note.txt/x.txt'), 'ECRITURE', refus('note.txt/x.txt', sousNote));
await fs.supprimer(DOCUMENTS, 'note.txt/x.txt');
expect(await fs.lister(DOCUMENTS, '')).toMatchObject([
{ nom: 'corbeille', type: 'dossier' },
{ nom: 'note.txt', type: 'fichier' },
]);
expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]);
expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note');
});
// Chaque panne frappe un temps de l'écriture par flux : son ouverture,
// l'écriture, la fermeture qui porte le contenu sur la cible.
const PANNES = [
['ouverture du flux', () => FileSystemFileHandle.prototype, 'createWritable', 'NoModificationAllowedError'],
["écriture dans le flux", () => FileSystemWritableFileStream.prototype, 'write', 'QuotaExceededError'],
['fermeture du flux', () => FileSystemWritableFileStream.prototype, 'close', 'InvalidStateError'],
];
test("une panne de l'OPFS lève ECRITURE avec le nom de son exception, laisse la cible intacte et ne laisse aucun fichier neuf ni fichier d'échange", async () => {
const fs = await preparer();
await fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'avant');
await fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'premiere');
for (const [temps, prototype, methode, cause] of PANNES) {
vi.spyOn(prototype(), methode).mockRejectedValue(new DOMException(temps, cause));
const appels = [
['etat.json', () => fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'apres')],
['neuf.json', () => fs.ecrireAtomique(DOCUMENTS, 'neuf.json', 'apres')],
['journal.jsonl', () => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'seconde')],
['neuf.jsonl', () => fs.ajouterLigne(DOCUMENTS, 'neuf.jsonl', 'seconde')],
];
for (const [chemin, appel] of appels) {
await echoue(appel(), 'ECRITURE', { chemin, dossier: CHEMIN_DOCUMENTS, cause }, `${temps} : ${chemin}`);
}
vi.restoreAllMocks();
expect(await fs.lireTexte(DOCUMENTS, 'etat.json'), temps).toBe('avant');
expect(await fs.lireTexte(DOCUMENTS, 'journal.jsonl'), temps).toBe('premiere\n');
expect(noms(await fs.lister(DOCUMENTS, '')), temps).toStrictEqual(['etat.json', 'journal.jsonl']);
}
// Un rejet qui n'est pas une exception nommée donne son texte pour cause.
vi.spyOn(FileSystemWritableFileStream.prototype, 'write').mockRejectedValueOnce('flux rompu');
await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'apres'), 'ECRITURE', {
chemin: 'etat.json',
dossier: CHEMIN_DOCUMENTS,
cause: 'flux rompu',
});
expect(await fs.lireTexte(DOCUMENTS, 'etat.json')).toBe('avant');
});
test('deplacer copie les octets de la source puis la retire ; un échec de la copie ou du retrait laisse la source seule', async () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
await poserOpfs('source.csv', BRUTS);
vi.spyOn(FileSystemWritableFileStream.prototype, 'write').mockRejectedValue(
new DOMException('copie', 'QuotaExceededError'),
);
await echoue(fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv'), 'ECRITURE', {
chemin: 'corbeille/source.csv',
dossier: `${CHEMIN_DOCUMENTS}/corbeille`,
cause: 'QuotaExceededError',
});
vi.restoreAllMocks();
expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]);
expect(await octetsOpfs('source.csv')).toStrictEqual(BRUTS);
vi.spyOn(FileSystemDirectoryHandle.prototype, 'removeEntry').mockRejectedValueOnce(
new DOMException('retrait', 'NoModificationAllowedError'),
);
await echoue(fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv'), 'ECRITURE', {
chemin: 'source.csv',
dossier: CHEMIN_DOCUMENTS,
cause: 'NoModificationAllowedError',
});
vi.restoreAllMocks();
expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]);
expect(await octetsOpfs('source.csv')).toStrictEqual(BRUTS);
await fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv');
expect(await octetsOpfs('corbeille', 'source.csv')).toStrictEqual(BRUTS);
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille']);
});
// Une lecture tombe en panne à l'un de ses temps : l'OPFS, que le
// navigateur refuse à l'origine ; la description d'un fichier ; ses octets,
// quand il change pendant qu'on le lit.
test("une lecture qui échoue autrement qu'en ne trouvant pas l'entrée lève LECTURE avec le nom de son exception, qui nomme le dossier affichable de l'entrée : une panne n'est pas une absence", async () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
await fs.ecrireAtomique(DOCUMENTS, 'corbeille/etat.json', 'etat');
const corbeille = `${CHEMIN_DOCUMENTS}/corbeille`;
const lecture = (chemin, dossier, cause) => ({ chemin, dossier, cause });
const etat = 'corbeille/etat.json';
vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce(new DOMException('change', 'NotReadableError'));
await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'NotReadableError'), 'octets');
vi.spyOn(FileSystemFileHandle.prototype, 'getFile').mockRejectedValueOnce(
new DOMException('illisible', 'NotReadableError'),
);
await echoue(
fs.lister(DOCUMENTS, 'corbeille'),
'LECTURE',
lecture('corbeille', CHEMIN_DOCUMENTS, 'NotReadableError'),
'description',
);
// Un rejet qui n'est pas une exception nommée donne son texte pour cause.
vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce('flux rompu');
await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'flux rompu'), 'rejet sans nom');
vi.spyOn(StorageManager.prototype, 'getDirectory').mockRejectedValue(new DOMException('refus', 'SecurityError'));
await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'SecurityError'), 'lireTexte');
await echoue(fs.lister(DOCUMENTS, ''), 'LECTURE', lecture('', CHEMIN_DOCUMENTS, 'SecurityError'), 'lister');
vi.restoreAllMocks();
expect(await fs.lireTexte(DOCUMENTS, etat)).toBe('etat');
});
test("lister omet un fichier qui disparaît pendant la liste, prend la date d'un fichier dans l'OPFS et date un dossier de 0", async () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
await fs.ecrireAtomique(DOCUMENTS, 'reste.txt', 'reste');
await fs.ecrireAtomique(DOCUMENTS, 'fugace.txt', 'fugace');
const modifie = (await (await (await dossierProduit()).getFileHandle('reste.txt')).getFile()).lastModified;
const lire = FileSystemFileHandle.prototype.getFile;
vi.spyOn(FileSystemFileHandle.prototype, 'getFile').mockImplementation(function getFile() {
return this.name === 'fugace.txt' ? Promise.reject(new DOMException('disparu', 'NotFoundError')) : lire.call(this);
});
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([
{ nom: 'corbeille', type: 'dossier', taille: 0, modifie: 0 },
{ nom: 'reste.txt', type: 'fichier', taille: 5, modifie },
]);
});
test("sonder rend la cause que lève l'OPFS, ou RELECTURE_DIFFERENTE, sans lever, et ne laisse aucun témoin", async () => {
const fs = creerFichiersWeb();
vi.spyOn(StorageManager.prototype, 'getDirectory').mockRejectedValueOnce(new DOMException('refus', 'SecurityError'));
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'SecurityError' });
expect(await nomsOpfs()).toStrictEqual([]);
vi.spyOn(FileSystemFileHandle.prototype, 'createWritable').mockRejectedValueOnce(
new DOMException('tenu', 'NoModificationAllowedError'),
);
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NoModificationAllowedError' });
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
vi.spyOn(Blob.prototype, 'arrayBuffer').mockResolvedValueOnce(new TextEncoder().encode('autre').buffer);
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'RELECTURE_DIFFERENTE' });
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
// Une relecture qui échoue suit l'écriture du témoin, que la sonde efface.
vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce(new DOMException('illisible', 'NotReadableError'));
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NotReadableError' });
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
vi.restoreAllMocks();
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: true, cause: null });
});
});
describe('fichiers web : ni verrou, ni explorateur, ni dossier choisi (§ 8.8)', () => {
test("verrouiller rend { pris: true } sans rien écrire, et deverrouiller n'efface aucun verrou", async () => {
const fs = await preparer();
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a')).toStrictEqual({ pris: true });
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a')).toStrictEqual({ pris: true });
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
// Un verrou venu d'un dossier de la coquille reste où il est.
const verrou = '{"seance":"seance-b","pid":4321,"hote":"poste-voisin","depuis":"jamais"}\n';
await fs.ecrireAtomique(DOCUMENTS, 'soiree.gtt.verrou', verrou);
await fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-b');
expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou);
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-c')).toStrictEqual({ pris: true });
expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou);
});
test("ouvrirDansExplorateur et choisirDossier lèvent NON_DISPONIBLE {primitive} ; une racine inconnue lève d'abord CHEMIN_REFUSE", async () => {
const fs = await preparer();
await echoue(fs.ouvrirDansExplorateur(DOCUMENTS), 'NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' });
await echoue(fs.choisirDossier(), 'NON_DISPONIBLE', { primitive: 'choisirDossier' });
const inconnue = { id: 'portable', chemin: CHEMIN_DOCUMENTS };
await echoue(fs.ouvrirDansExplorateur(inconnue), 'CHEMIN_REFUSE', { chemin: '' });
await echoue(fs.typeSupport(inconnue), 'CHEMIN_REFUSE', { chemin: '' });
});
test("deux casses ou deux formes de normalisation d'un nom sont deux fichiers, et deplacer vers une autre casse ne trouve pas de cible existante", async () => {
const fs = await preparer();
await fs.ecrireAtomique(DOCUMENTS, 'casse.txt', 'minuscules');
await fs.ecrireAtomique(DOCUMENTS, 'CASSE.txt', 'capitales');
await fs.ecrireAtomique(DOCUMENTS, '\u{E9}.txt', 'NFC');
await fs.ecrireAtomique(DOCUMENTS, 'e\u{301}.txt', 'NFD');
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['CASSE.txt', 'casse.txt', 'e\u{301}.txt', '\u{E9}.txt']);
expect(await fs.lireTexte(DOCUMENTS, 'casse.txt')).toBe('minuscules');
expect(await fs.lireTexte(DOCUMENTS, '\u{E9}.txt')).toBe('NFC');
await fs.deplacer(DOCUMENTS, 'casse.txt', 'Casse.txt');
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['CASSE.txt', 'Casse.txt', 'e\u{301}.txt', '\u{E9}.txt']);
expect(await fs.lireTexte(DOCUMENTS, 'Casse.txt')).toBe('minuscules');
expect(await fs.lireTexte(DOCUMENTS, 'CASSE.txt')).toBe('capitales');
});
});
describe('fichiers web : importer et enregistrer', () => {
test('choisirFichierAImporter ouvre un champ de fichier unique, hors du document, et rend le nom et les octets choisis tels quels', async () => {
const fs = creerFichiersWeb();
const champs = [];
vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function cliquer() {
champs.push({ type: this.type, multiple: this.multiple, dansLeDocument: this.isConnected });
const transfert = new DataTransfer();
transfert.items.add(new File([BRUTS], 'invit\u{E9}s.csv'));
this.files = transfert.files;
this.dispatchEvent(new Event('change'));
});
const choisi = await fs.choisirFichierAImporter();
expect(champs).toStrictEqual([{ type: 'file', multiple: false, dansLeDocument: false }]);
expect(choisi).toStrictEqual({ nom: 'invit\u{E9}s.csv', octets: BRUTS });
});
test("choisirFichierAImporter rend null quand l'opérateur annule, ou que le champ revient sans fichier", async () => {
const fs = creerFichiersWeb();
const clic = vi.spyOn(HTMLInputElement.prototype, 'click');
clic.mockImplementationOnce(function annuler() {
this.dispatchEvent(new Event('cancel'));
});
expect(await fs.choisirFichierAImporter()).toBeNull();
clic.mockImplementationOnce(function revenirVide() {
this.dispatchEvent(new Event('change'));
});
expect(await fs.choisirFichierAImporter()).toBeNull();
});
test("un fichier choisi qui ne se lit pas lève LECTURE, ou ABSENT quand il a disparu, et se nomme par son seul nom : le navigateur tait son dossier", async () => {
const fs = creerFichiersWeb();
vi.spyOn(Blob.prototype, 'arrayBuffer')
.mockRejectedValueOnce(new DOMException('change', 'NotReadableError'))
.mockRejectedValueOnce(new DOMException('disparu', 'NotFoundError'));
vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function cliquer() {
const transfert = new DataTransfer();
transfert.items.add(new File([BRUTS], 'invit\u{E9}s.csv'));
this.files = transfert.files;
this.dispatchEvent(new Event('change'));
});
await echoue(fs.choisirFichierAImporter(), 'LECTURE', {
chemin: 'invit\u{E9}s.csv',
dossier: null,
cause: 'NotReadableError',
});
await echoue(fs.choisirFichierAImporter(), 'ABSENT', { chemin: 'invit\u{E9}s.csv' });
});
test("enregistrerSous remet au navigateur un lien de téléchargement des octets sous le nom proposé, rend ce nom, puis libère l'adresse", async () => {
const fs = creerFichiersWeb();
const creer = vi.spyOn(URL, 'createObjectURL');
const liberer = vi.spyOn(URL, 'revokeObjectURL');
const liens = [];
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function cliquer() {
liens.push({
href: this.href,
download: this.download,
dansLeDocument: this.isConnected,
liberee: liberer.mock.calls.length > 0,
});
});
expect(await fs.enregistrerSous('invit\u{E9}s.csv', BRUTS)).toBe('invit\u{E9}s.csv');
expect(creer).toHaveBeenCalledTimes(1);
const [contenu] = creer.mock.calls[0];
const adresse = creer.mock.results[0].value;
expect(contenu).toBeInstanceOf(Blob);
expect(contenu.type).toBe('application/octet-stream');
expect(new Uint8Array(await contenu.arrayBuffer())).toStrictEqual(BRUTS);
expect(liens).toStrictEqual([
{ href: adresse, download: 'invit\u{E9}s.csv', dansLeDocument: false, liberee: false },
]);
await vi.waitFor(() => expect(liberer).toHaveBeenCalledWith(adresse));
expect(liberer).toHaveBeenCalledTimes(1);
});
});
describe('fichiers web : fautes de programmation', () => {
test('un texte, une ligne, une séance, un nom ou des octets mal formés lèvent TypeError, sans rien écrire ni rien proposer', async () => {
const fs = await preparer();
const creer = vi.spyOn(URL, 'createObjectURL');
const fautes = [
() => fs.ecrireAtomique(DOCUMENTS, 'etat.json', 42),
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 42),
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'deux\nlignes'),
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'retour\rchariot'),
() => fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', ''),
() => fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', null),
() => fs.enregistrerSous('', BRUTS),
() => fs.enregistrerSous('liste.csv', 'nom;prenom'),
];
for (const faute of fautes) await expect(faute(), String(faute)).rejects.toThrow(TypeError);
expect(creer).not.toHaveBeenCalled();
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
});
});