diff --git a/.gitignore b/.gitignore index 1b61721..98f57ff 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ ios/ .vitest/ coverage/ scripts/banc/sorties/ +doc/captures/.attente/ diff --git a/Makefile b/Makefile index feb6835..8ae2558 100644 --- a/Makefile +++ b/Makefile @@ -76,6 +76,13 @@ mutation: ## Audit par mutation du moteur, avant une livraison (§ 14.13) banc: ## Relève les constantes du banc de mesure (§ 19.10), avant une livraison $(AVEC_NODE) npm run banc +# GTT_CAPTURES, exportée pour la seule recette, se pose de la même façon sous +# Windows : le projet navigateur publie doc/captures au lieu de le comparer. +.PHONY: captures +captures: export GTT_CAPTURES := ecrire +captures: ## Photographie le parcours et publie doc/captures (§ 19.5) + $(AVEC_NODE) npx vitest run --project navigateur src/interface/parcours.navigateur.test.js + .PHONY: build build: ## Construit la page dans www/ $(AVEC_NODE) npm run build diff --git a/doc/captures/01-premier-ecran.png b/doc/captures/01-premier-ecran.png new file mode 100644 index 0000000..9c615aa Binary files /dev/null and b/doc/captures/01-premier-ecran.png differ diff --git a/doc/captures/01b-passer-en-ecriture.png b/doc/captures/01b-passer-en-ecriture.png new file mode 100644 index 0000000..1e99797 Binary files /dev/null and b/doc/captures/01b-passer-en-ecriture.png differ diff --git a/doc/captures/02-import-apercu.png b/doc/captures/02-import-apercu.png new file mode 100644 index 0000000..e613ac7 Binary files /dev/null and b/doc/captures/02-import-apercu.png differ diff --git a/doc/captures/02b-import-cp1252.png b/doc/captures/02b-import-cp1252.png new file mode 100644 index 0000000..71a56ab Binary files /dev/null and b/doc/captures/02b-import-cp1252.png differ diff --git a/doc/captures/03-import-rapport.png b/doc/captures/03-import-rapport.png new file mode 100644 index 0000000..66b214d Binary files /dev/null and b/doc/captures/03-import-rapport.png differ diff --git a/doc/captures/04-grille-participants.png b/doc/captures/04-grille-participants.png new file mode 100644 index 0000000..8ac7e60 Binary files /dev/null and b/doc/captures/04-grille-participants.png differ diff --git a/doc/captures/05-tables-etat.png b/doc/captures/05-tables-etat.png new file mode 100644 index 0000000..7f7d7f5 Binary files /dev/null and b/doc/captures/05-tables-etat.png differ diff --git a/doc/captures/06-defaut-consequence.png b/doc/captures/06-defaut-consequence.png new file mode 100644 index 0000000..86ab39f Binary files /dev/null and b/doc/captures/06-defaut-consequence.png differ diff --git a/doc/captures/07-plan-tables.png b/doc/captures/07-plan-tables.png new file mode 100644 index 0000000..b4b1d79 Binary files /dev/null and b/doc/captures/07-plan-tables.png differ diff --git a/doc/captures/07a-cibles-sieges.png b/doc/captures/07a-cibles-sieges.png new file mode 100644 index 0000000..aada406 Binary files /dev/null and b/doc/captures/07a-cibles-sieges.png differ diff --git a/doc/captures/07a-cibles-tables.png b/doc/captures/07a-cibles-tables.png new file mode 100644 index 0000000..7c64107 Binary files /dev/null and b/doc/captures/07a-cibles-tables.png differ diff --git a/doc/captures/07b-nombre-de-tours.png b/doc/captures/07b-nombre-de-tours.png new file mode 100644 index 0000000..a31b690 Binary files /dev/null and b/doc/captures/07b-nombre-de-tours.png differ diff --git a/doc/captures/08-animateurs-designes.png b/doc/captures/08-animateurs-designes.png new file mode 100644 index 0000000..2dfcce9 Binary files /dev/null and b/doc/captures/08-animateurs-designes.png differ diff --git a/doc/captures/09-refus-sur-place.png b/doc/captures/09-refus-sur-place.png new file mode 100644 index 0000000..d01aa7c Binary files /dev/null and b/doc/captures/09-refus-sur-place.png differ diff --git a/doc/captures/10-diagnostic.png b/doc/captures/10-diagnostic.png new file mode 100644 index 0000000..3a72049 Binary files /dev/null and b/doc/captures/10-diagnostic.png differ diff --git a/doc/captures/11-propositions.png b/doc/captures/11-propositions.png new file mode 100644 index 0000000..3555cee Binary files /dev/null and b/doc/captures/11-propositions.png differ diff --git a/doc/captures/19-ajustement.png b/doc/captures/19-ajustement.png new file mode 100644 index 0000000..58248f7 Binary files /dev/null and b/doc/captures/19-ajustement.png differ diff --git a/doc/captures/20-mode-lecture.png b/doc/captures/20-mode-lecture.png new file mode 100644 index 0000000..f1c50ff Binary files /dev/null and b/doc/captures/20-mode-lecture.png differ diff --git a/doc/captures/manifeste.json b/doc/captures/manifeste.json new file mode 100644 index 0000000..dcc22f3 --- /dev/null +++ b/doc/captures/manifeste.json @@ -0,0 +1,29 @@ +{"captures":[ +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"3d457536d0600103ea1141c08871a4aebb7c5ef455311fe8201a817f38d7bca0","etape":"1","etat":null,"facteurEchelle":1,"fichier":"01-premier-ecran","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"22433fa483e23340e474b89d0dcf084f9ab6b6d254f3867fe5006846ee1fb0a1"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Clair","Sombre","À propos","Liste des événements","version","construction de documentation","Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents.","Dossier de travail","C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre","Ouvrir dans l’explorateur","Créer un événement","Nom de l’événement","Date, facultative (AAAA-MM-JJ)","Créer l’événement","Importer un événement depuis un fichier…","Démonstrations","Chacune se charge comme un nouvel événement, numéroté et ouvert en lecture ; aucun autre événement n’est touché.","Grande démonstration","Grande démonstration sans exception","Petite démonstration","Petite démonstration, conflit inévitable"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"18f2d427edc5d649e7c9391843d78a0dd6c441e24de40968c5235e07a60d39cd","etape":"1b","etat":"ab468ad775d39cda60057d23f67ba34d787dda85b66c87b5500b41bff1b2b630","facteurEchelle":1,"fichier":"01b-passer-en-ecriture","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"983dde9ee5e27d64c9741ac634e04cda267835484a923dc3c0b5e095fbd556f8"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":5.29,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-refus","ratio":8.76,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":7.34,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5},{"jeton":"--couleur-refus","ratio":9.24,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Modifier","Lecture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Tables et tours","Places par défaut","Nombre de tours","Tables","Ajouter une table ronde","L'événement est ouvert en lecture : « Modifier » permet de le changer.","Modifier","Ajouter une table carrée","Aucune table : ajoutez-en une, ronde ou carrée."],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"e3bdd85dca712877cfe2cb2f2b6411dde35f401473ec8aec26b4efd0df9beb91","etape":"2","etat":"4acf3230b0733a35d33c6baf84b7d4f312e98a5b799147dcea6b3bb5d4f082d5","facteurEchelle":1,"fichier":"02-import-apercu","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"7ac33f9edc7df268ba3a5d21511f9cfaa5c623b3e2e8eb7d4ae132b20d83205c"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Aperçu","Mode de l'import","Ajouter","Mettre à jour","Remplacer","Fichier","participants_cas_limites.csv","Encodage","UTF-8 avec marque","Séparateur","point-virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","4","Marbrelle","Gaspard","Cercle des Saules","animation","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Lignes refusées","Ligne","Motif","13","Le nom manque.","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Doublons signalés, jamais fusionnés","lignes","2","10","Importer","Enregistrer les lignes refusées","Fermer l'aperçu"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"6174e5e9194ef74b8730b18e444723596ac85d0bc0a21272cfe976555671d9df","etape":"2","etat":"4acf3230b0733a35d33c6baf84b7d4f312e98a5b799147dcea6b3bb5d4f082d5","facteurEchelle":1,"fichier":"02b-import-cp1252","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"42391359098507ccc1bbac0b9ee4230e0aed3cd49acabe090518a390e82ae704"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Aperçu","Mode de l'import","Ajouter","Mettre à jour","Remplacer","Fichier","participants_cas_limites_cp1252.csv","Encodage","windows-1252","Séparateur","virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","4","Marbrelle","Gaspard","Cercle des Saules","animation","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Lignes refusées","Ligne","Motif","13","Le nom manque.","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Doublons signalés, jamais fusionnés","lignes","2","10","Importer","Enregistrer les lignes refusées","Fermer l'aperçu"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"3a44dd113f4b0585f56ea6009936b4ab1ed89d4e9efe6219ad175b0d62285c21","etape":"2","etat":"b3119a32dda90f0c824bb31f31cb2f5a58af891b3338215d7efabf785b0a5d48","facteurEchelle":1,"fichier":"03-import-rapport","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"dab7ed78b70211fba11d29f9d25a8ed1ad8c78325b6095844087b9de782c4dc2"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Il manque 4 places à chaque tour pour asseoir toutes les personnes : ajouter des tables ou des sièges, ou exclure des personnes, rend la génération possible.","Ouvrir les tables","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Rapport d'import","13","personnes ajoutées","0","personne mise à jour","0","personne inchangée","0","personne retirée","0","réservation retirée","Fichier","participants_cas_limites.csv","Encodage","UTF-8 avec marque","Séparateur","point-virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","4","Marbrelle","Gaspard","Cercle des Saules","animation","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Lignes refusées","Ligne","Motif","13","Le nom manque.","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Doublons signalés, jamais fusionnés","lignes","2","10","Enregistrer les lignes refusées","Fermer le rapport"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"ee8c4993e3e41b5f12fa7e487920d4f89147c7de2a509ea532c88a5b26fc70b7","etape":"3","etat":"a220b2f80cc83728c43e0bcca0bb785ce7e7b4fa5c3fea5289d095d09a911535","facteurEchelle":1,"fichier":"04-grille-participants","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"f182c0f76642f0fd76bb9f2a05e0de43a4b6d4b65da5b2c0497e79825f401262"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Il manque 4 places à chaque tour pour asseoir toutes les personnes : ajouter des tables ou des sièges, ou exclure des personnes, rend la génération possible.","Ouvrir les tables","Participants","Ajouter une personne","Nom","Prénom","Appartenance","Courriel","Titre pressenti","Notes","Ajouter","Filtrer","Grouper par","Aucun groupement","Nom","Prénom","Appartenance","Courriel","Titre pressenti","Exclusion","Notes","Statut","13 participants","Exporter la liste","Nom","(décroissant)","Prénom","Appartenance","Courriel","Titre pressenti","Exclusion","Notes","Actions","Tourterel","Héloïse","Cercle des Saules","exclue","S’est désistée la veille","Fiche","Voir sur le plan","Réintégrer","Supprimer","Sablonnet","Ysaline","Chœur de l’Anse","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Ormelin","Faustine","Société des Hérons","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Marbrelle","Gaspard","Cercle des Saules","ondine@exemple.test","animation","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Lanterneau","Basile","Chœur de l’Anse","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Fenouillet","Côme","Société des Hérons","présente","végétarien; sans noix, ni arachides","Fiche","Voir sur le plan","Exclure","Supprimer","Cressonnier","Bertille","Cercle des Saules","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Coudraie","Apolline","Chœur de l’Anse","présente","Arrive après le premier tour repart avant le dessert","Fiche","Voir sur le plan","Exclure","Supprimer","Brunœil","Cercle des Saules","brunoeil@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Ardoisier","Edgar","Société des Hérons","présente","Fiche","Voir sur le plan","Exclure","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"9e4224610e8ebd2c57a76c312230fed74cd889948ec42084392b5b744eb86e04","etape":"4","etat":"421c6294b4348fa49aa0105ea643fd11c80806a9fbb1e079435adac079e6b260","facteurEchelle":1,"fichier":"05-tables-etat","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"37dcb4e6b45de5e66feb2fb9d25675e7beebfce09e52eed6de14098353bfd392"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Tables et tours","Places par défaut","Nombre de tours","Tables","Ajouter une table ronde","Ajouter une table carrée","Table","1","Ronde","Suit le défaut","Places","Supprimer","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"6f910f302b41f1fb4e3262fd6f9da5d06a4f5f1ecc82fe1024e1743197b4662f","etape":"4","etat":"421c6294b4348fa49aa0105ea643fd11c80806a9fbb1e079435adac079e6b260","facteurEchelle":1,"fichier":"06-defaut-consequence","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"ca63fc45db4cc745b8b2d4238e48cd50b79b6e24469ae087fe344de7f182871c"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Tables et tours","Places par défaut","1 table suit le défaut et passe de 8 à 10 places ; 1 table surchargée (8) ne change pas.","Total : 16 → 18 sièges pour 12 participants.","Appliquer","Appliquer et réaligner la surchargée","Garder le défaut actuel","Nombre de tours","Tables","Ajouter une table ronde","Ajouter une table carrée","Table","1","Ronde","Suit le défaut","Places","Supprimer","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"df353cbd2ef92f6cae9fd7dc1fb51245caba287db9eb0327d583ce3d46925bdc","etape":"4","etat":"7d42132a2caa999f9d046f580b1093732ebb44984b8fdec5b27da7e42801678e","facteurEchelle":1,"fichier":"07-plan-tables","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"23f2e6e11539afac2867b17187ed851b2e5d0c334c8d40c12db4b2fc6e07d943"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":15.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.58,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Plan","Réservations","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","1. (libre)","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","9. (libre)","10. (libre)","1. (libre)","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 12 mobiles","Listes de travail","Titres de place non pourvus","Aucun.","Titres pressentis sans réservation","Basile Lanterneau","— titre pressenti","animation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Basile Lanterneau","Capucine Pommerel","Gaspard Marbrelle","Ysaline Sablonnet","Anselme Givrecœur","Côme Fenouillet","Apolline Coudraie","Basile Lanterneau","Brunœil","Faustine Ormelin","Edgar Ardoisier","Bertille Cressonnier"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"7e8e359a42eb8908ef0ba0462c886bea152496853d400daf5cc21dacb3682d34","etape":"4","etat":"4d37d30748068354162752743c26aa7e0ab00d6e9b2aa1996b6f9fb410848280","facteurEchelle":1,"fichier":"07a-cibles-sieges","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"18396caf4c054bdc9a23223eb6104838ebbd07183f0868277a00419177810767"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":5.29,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":15.38,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":15.1,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.58,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":7.34,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Modifier","Lecture","Défaire","Grande démonstration 1","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Plan","Réservations","Tour affiché","1","2","3","4","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","1. (réservée, tous les tours)","Manon Urvançon","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Guillaume Luskic…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Fatima Korvouze","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Antoine Bralmault","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Manon Tolveron","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Philippe Voskeval","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Félix Voskande","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Michel Korveron","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Gabriel Ivreval","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Marc Vandergrol…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Étienne Tolvançon","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Sophie Pruvotte","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Fatima Abrevanç…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Marie Urvard","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Catherine Hulver…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Camille Neskard","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Youssef Neskière","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Guillaume Dulvivet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Philippe Ivroubre","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Claire Jusvivet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Youssef Jusvard","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Louise Neskichon","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Hugo Bralmouze","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Gabriel Abrevault","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Louis Selvivet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Jean Fralvumet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Marie Bralmesson","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Thomas Selvignac","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Émilie Abrevière","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Geneviève Dulvu…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","1. (réservée, tous les tours)","François Obrouze","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","1. (réservée, tous les tours)","François Ivrivet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","1. (réservée, tous les tours)","Samuel Bralmeux","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","écart d'itinéraire maximal : plancher 1, au moins 24 mobiles finissent sous leur plafond a priori","plafond a priori 24 rencontres pour 4 ancrés","plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles","99 retours imposés par 33 ancrages","Listes de travail","Titres de place non pourvus","Aucun.","Titres pressentis sans réservation","Aucun.","Réserve","Personnes en attente au tour 1","Isabelle Mirvault","Félix Abrevande","Hugo Fralvançon","Manon Fralveux","Catherine Luskotte","Zoé WAMMERWOLM-MAWWEMMEL","Catherine Urvesson","Karim Hulvivet","Caroline Selveron","Catherine Korvault","Caroline Fralvotte","Geneviève Jusvesson","Paul Abrevumet","Léa Fralvault","Fatima Obrivet","Zoé Dulveval","Paul Korveux","Julie Luskière","Émilie Clovande","Paul Hulvande","Hugo Fralvesson","Isabelle Urvière","Thomas Rozvotte","Isabelle Bralmumet","Catherine Dulvouze","Marc Voskichon","Simon Mirvivet","Louise Clovault","Lucie Voskeux","Amina Eskignac","Luc Selvoubre","Étienne Fralvichon","Philippe Voskivet","Louise Quilmançon","Sophie Luskande","Pierre Abreveron","Philippe Neskeux","Amina Neskançon","Léa Tolvande","Antoine Quilmeron","Guillaume Clovumet","Michel Eskeux","Louise Hulvault","Samuel Eskumet","Claire Fralvivet","Léa Mirveux","Karim Gorzesson","Sylvie Quilmande","François Gorzichon","Amina Pruvesson","Lucie Quilmière","Fatima Korvesson","Nicolas Selvesson","Louise Neskesson","Lucie Tolvesson","Olivier Quilmesson","Guillaume Luskivet","Gabriel Neskault","Antoine Urvumet","Olivier Jusvançon","Simon Dulvançon","Chloé Gorzard","Gabriel Pruveux","Manon Clovière","Karim Tolveval","Hugo Bérêçàuvïlle-Dœûmôntëÿ","Claire Gorzeron","Hélène Luskumet","Louis Fralvande","Émilie Selvault","Léa Neskumet","Isabelle Korvichon","Michel Obrande","Isabelle Luskeval","Fatima Tolvoubre","Zoé Gorzière","Sylvie Korvignac","Antoine Obreval","Camille Tolvumet","Marie Dulvesson","Guillaume Tolvignac","Gabriel Mirvotte","Paul Neskignac","Raphaël Ivrotte","Marc Luskouze","Sylvie Jusvotte","Véronique Bralmoubre","Pierre Selvard","Marie Hulveux","Karim Fralveval","Philippe Gorzignac","Étienne Selvouze","Louis Hulvichon","Sylvie Urvoubre","Pierre Pruveval","Guillaume Hulvotte","Geneviève Abrevignac","Amina Bralmignac","Olivier Bralmançon","Marc Luskoubre","Geneviève Abrevoubre","Raphaël Korvoubre","Pierre Mirvard","Camille Jusvumet","Samuel Cloveval","Antoine Selvière","Véronique Jusvouze","Paul Neskivet","Samuel Dulvande","Lucie Selveval","Olivier Tolvichon","Fatima Hulvard","Alexandre Ivreux","Félix Korveval","Karim Urvichon","Émilie Dulvichon","Luc Tolvouze","Amina Quilmault","François Ivrouze","Hélène Gorzumet","Sophie Ivrault","Louise Bralmière","Nicolas Korvande","Geneviève Ivrumet","Geneviève Obresson","Guillaume Obrançon","Fatima Urvignac","Claire Bralmotte","Félix Hulvignac","François Clovotte","Philippe Dulvoubre","Samuel Rozveron","Pierre Hulvoubre","Émilie Urvouze","Félix Korvançon","Émilie Neskande","Chantal Mirvumet","Alexandre Korvotte","Zoé Eskeval","Nathalie Voskoubre","Sophie Pruvière","Paul Obrichon","Nathalie Eskière","Caroline Ivreron","Véronique Neskoubre","Anne Urvivet","Olivier Hulvançon","Karim Clovançon","Zoé Quilmotte","Félix Eskouze","Amina Rozvichon","Simon Fralvière","Pierre Hulvière","Raphaël Jusvault","Pierre Fralvoubre","Hélène Selveux","Caroline Rozvançon","Olivier Korvière","Nicolas Ivrichon","Félix Korvumet","Guillaume Selvumet","Michel Urvotte","Émilie Eskault","Hélène Bralmeval","Guillaume Hulvesson","Caroline Jusvoubre","Félix Luskeux","Antoine Pruvouze","Julie Quilmignac","Hugo Voskumet","Manon Quilmumet","Marc Voskouze","Samuel Abrevard","Manon Clovesson","Simon Hulvumet","Lucie Voskeron","Jean Neskeron","Manon Pruvivet","Sophie Clovard","Étienne Gorzeval","Gabriel Luskault","Émilie Korvard","Thomas Quilmichon","Alexandre Eskançon","Émilie Luskançon","Marc Dulvault","Hélène Rozveval","Julie Pruvançon","Louis Mirveron","François Eskard","Louis Ivrançon","Karim Clovivet","Léa Rozvande","Amina Hulvouze","Étienne Eskesson","Philippe Abreveval","Émilie Quilmeux","Simon Voskotte","Chantal Obrignac","Guillaume Eskotte","Léa Obrard","Guillaume Gorzivet","Geneviève Mirvançon","Julie Selvotte","Amina Mirvesson","Isabelle Luskignac","Chloé Obrumet","Anne Abrevesson","Fatima Rozvumet","Guillaume Mirvoubre","Véronique Rozvoubre","Claire Gorzotte","Antoine Ivrière","Isabelle Selvichon","Isabelle Urvault","Julie Voskesson","Chantal Abrevotte","Amina Neskotte","Étienne Voskançon","Zoé Fralveron","Marc Rozvouze","Sylvie Abreveux","Karim Pruvignac","Zoé Mirveval","Philippe Fralvignac","Gabriel Gorzeux","Jean Urveval"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"41b9b3979a70ee689aac4187240a636fcc5d19bc2447d5d42ab172d58f53bbc4","etape":"4","etat":"4d37d30748068354162752743c26aa7e0ab00d6e9b2aa1996b6f9fb410848280","facteurEchelle":1,"fichier":"07a-cibles-tables","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"a0ca6b13c5c44500df3f250c0eb9cf6d9e0f322ef7c1e37afeb644f5c986f98f"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":5.29,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":15.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":8.13,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":7.34,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Modifier","Lecture","Défaire","Grande démonstration 1","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Plan","Réservations","Tour affiché","1","2","3","4","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","5","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","écart d'itinéraire maximal : plancher 1, au moins 24 mobiles finissent sous leur plafond a priori","plafond a priori 24 rencontres pour 4 ancrés","plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles","99 retours imposés par 33 ancrages","Listes de travail","Titres de place non pourvus","Aucun.","Titres pressentis sans réservation","Aucun.","Réserve","Personnes en attente au tour 1","Isabelle Mirvault","Félix Abrevande","Hugo Fralvançon","Manon Fralveux","Catherine Luskotte","Zoé WAMMERWOLM-MAWWEMMEL","Catherine Urvesson","Karim Hulvivet","Caroline Selveron","Catherine Korvault","Caroline Fralvotte","Geneviève Jusvesson","Paul Abrevumet","Léa Fralvault","Fatima Obrivet","Zoé Dulveval","Paul Korveux","Julie Luskière","Émilie Clovande","Paul Hulvande","Hugo Fralvesson","Isabelle Urvière","Thomas Rozvotte","Isabelle Bralmumet","Catherine Dulvouze","Marc Voskichon","Simon Mirvivet","Louise Clovault","Lucie Voskeux","Amina Eskignac","Luc Selvoubre","Étienne Fralvichon","Philippe Voskivet","Louise Quilmançon","Sophie Luskande","Pierre Abreveron","Philippe Neskeux","Amina Neskançon","Léa Tolvande","Antoine Quilmeron","Guillaume Clovumet","Michel Eskeux","Louise Hulvault","Samuel Eskumet","Claire Fralvivet","Léa Mirveux","Karim Gorzesson","Sylvie Quilmande","François Gorzichon","Amina Pruvesson","Lucie Quilmière","Fatima Korvesson","Nicolas Selvesson","Louise Neskesson","Lucie Tolvesson","Olivier Quilmesson","Guillaume Luskivet","Gabriel Neskault","Antoine Urvumet","Olivier Jusvançon","Simon Dulvançon","Chloé Gorzard","Gabriel Pruveux","Manon Clovière","Karim Tolveval","Hugo Bérêçàuvïlle-Dœûmôntëÿ","Claire Gorzeron","Hélène Luskumet","Louis Fralvande","Émilie Selvault","Léa Neskumet","Isabelle Korvichon","Michel Obrande","Isabelle Luskeval","Fatima Tolvoubre","Zoé Gorzière","Sylvie Korvignac","Antoine Obreval","Camille Tolvumet","Marie Dulvesson","Guillaume Tolvignac","Gabriel Mirvotte","Paul Neskignac","Raphaël Ivrotte","Marc Luskouze","Sylvie Jusvotte","Véronique Bralmoubre","Pierre Selvard","Marie Hulveux","Karim Fralveval","Philippe Gorzignac","Étienne Selvouze","Louis Hulvichon","Sylvie Urvoubre","Pierre Pruveval","Guillaume Hulvotte","Geneviève Abrevignac","Amina Bralmignac","Olivier Bralmançon","Marc Luskoubre","Geneviève Abrevoubre","Raphaël Korvoubre","Pierre Mirvard","Camille Jusvumet","Samuel Cloveval","Antoine Selvière","Véronique Jusvouze","Paul Neskivet","Samuel Dulvande","Lucie Selveval","Olivier Tolvichon","Fatima Hulvard","Alexandre Ivreux","Félix Korveval","Karim Urvichon","Émilie Dulvichon","Luc Tolvouze","Amina Quilmault","François Ivrouze","Hélène Gorzumet","Sophie Ivrault","Louise Bralmière","Nicolas Korvande","Geneviève Ivrumet","Geneviève Obresson","Guillaume Obrançon","Fatima Urvignac","Claire Bralmotte","Félix Hulvignac","François Clovotte","Philippe Dulvoubre","Samuel Rozveron","Pierre Hulvoubre","Émilie Urvouze","Félix Korvançon","Émilie Neskande","Chantal Mirvumet","Alexandre Korvotte","Zoé Eskeval","Nathalie Voskoubre","Sophie Pruvière","Paul Obrichon","Nathalie Eskière","Caroline Ivreron","Véronique Neskoubre","Anne Urvivet","Olivier Hulvançon","Karim Clovançon","Zoé Quilmotte","Félix Eskouze","Amina Rozvichon","Simon Fralvière","Pierre Hulvière","Raphaël Jusvault","Pierre Fralvoubre","Hélène Selveux","Caroline Rozvançon","Olivier Korvière","Nicolas Ivrichon","Félix Korvumet","Guillaume Selvumet","Michel Urvotte","Émilie Eskault","Hélène Bralmeval","Guillaume Hulvesson","Caroline Jusvoubre","Félix Luskeux","Antoine Pruvouze","Julie Quilmignac","Hugo Voskumet","Manon Quilmumet","Marc Voskouze","Samuel Abrevard","Manon Clovesson","Simon Hulvumet","Lucie Voskeron","Jean Neskeron","Manon Pruvivet","Sophie Clovard","Étienne Gorzeval","Gabriel Luskault","Émilie Korvard","Thomas Quilmichon","Alexandre Eskançon","Émilie Luskançon","Marc Dulvault","Hélène Rozveval","Julie Pruvançon","Louis Mirveron","François Eskard","Louis Ivrançon","Karim Clovivet","Léa Rozvande","Amina Hulvouze","Étienne Eskesson","Philippe Abreveval","Émilie Quilmeux","Simon Voskotte","Chantal Obrignac","Guillaume Eskotte","Léa Obrard","Guillaume Gorzivet","Geneviève Mirvançon","Julie Selvotte","Amina Mirvesson","Isabelle Luskignac","Chloé Obrumet","Anne Abrevesson","Fatima Rozvumet","Guillaume Mirvoubre","Véronique Rozvoubre","Claire Gorzotte","Antoine Ivrière","Isabelle Selvichon","Isabelle Urvault","Julie Voskesson","Chantal Abrevotte","Amina Neskotte","Étienne Voskançon","Zoé Fralveron","Marc Rozvouze","Sylvie Abreveux","Karim Pruvignac","Zoé Mirveval","Philippe Fralvignac","Gabriel Gorzeux","Jean Urveval"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"def31a8b9a29d547c788b037763e62ad83e0ab45dfb0a1f7c462ef5602641dea","etape":"5","etat":"7e19a3b61721017693ee5895e1b4a82e2f1656e35e71989ac4d29ea9cfc59fe8","facteurEchelle":1,"fichier":"07b-nombre-de-tours","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"cfb03caea84f7ef00343f710d2db08807e8b67273d9b5d24d8dcf8266773c9be"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Tables et tours","Places par défaut","Nombre de tours","Passer de 3 à 2 tours ? Ce changement emporte :","Basile Lanterneau : réservation à la table 1 au tour 3, détruite","Annuler","Confirmer","Tables","Ajouter une table ronde","Ajouter une table carrée","Table","1","Ronde","Suit le défaut","Places","Supprimer","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"7e9d50eb42534aca92ac1c25418df31a6cf39ad65aa4b7b7d131056d2002e98a","etape":"6","etat":"0ad50f28307185683e15e5660c2da2f3398d783d68988d637c519ef3ba0658a5","facteurEchelle":1,"fichier":"08-animateurs-designes","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"c635998d91cfba432e1355807acb2a417152256321b205c3606fa602b324ae4f"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":15.38,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":15.1,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-selection","ratio":4.66,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.58,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":15.21,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-selection","ratio":4.18,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Plan","Réservations","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","9. (libre)","10. (libre)","1. (libre)","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 1 ancré et 11 mobiles","2 retours imposés par 1 ancrage","Listes de travail","Titres de place non pourvus","table 2 : titre","animation","non pourvu","Titres pressentis sans réservation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Gaspard Marbrelle","Ysaline Sablonnet","Anselme Givrecœur","Côme Fenouillet","Apolline Coudraie","Basile Lanterneau","Brunœil","Faustine Ormelin","Edgar Ardoisier","Bertille Cressonnier","Table 2, siège 1","1. (libre)","— titre","animation","non pourvu","Choisissez d'abord une personne : dans la réserve, dans la liste des pressentis, ou par « Déplacer » dans son menu.","Portée","Tour 1","Tous les tours","Libellé du titre","Assigner","Réserver","Désigner","Retirer le titre"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"1dac90f8e72931ca0be3f940d047311709153b4979a8094c74454e681fea09a7","etape":"7","etat":"0ad50f28307185683e15e5660c2da2f3398d783d68988d637c519ef3ba0658a5","facteurEchelle":1,"fichier":"09-refus-sur-place","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"ec44cf4519fc7245432c5ad3d9659633f84327cc9ab3a62fd27da04deb27f65e"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":15.38,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":15.1,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-refus","ratio":8.76,"role":"texte","seuil":4.5},{"jeton":"--couleur-selection","ratio":4.66,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.58,"role":"marque","seuil":3},{"jeton":"rgb(0, 0, 0)","ratio":18.26,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":15.21,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-refus","ratio":9.24,"role":"texte","seuil":4.5},{"jeton":"--couleur-selection","ratio":4.18,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3},{"jeton":"rgb(255, 255, 255)","ratio":5.33,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Plan","Réservations","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","9. (libre)","10. (libre)","1. (libre)","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","Table 1, siège 1","Le siège 1 est réservé à une autre personne à tous les tours : déplacer sa réservation libère la place.","Choisir une place","Fermer","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 1 ancré et 11 mobiles","2 retours imposés par 1 ancrage","Listes de travail","Titres de place non pourvus","table 2 : titre","animation","non pourvu","Titres pressentis sans réservation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Gaspard Marbrelle","Ysaline Sablonnet","Anselme Givrecœur","Côme Fenouillet","Apolline Coudraie","Basile Lanterneau","Brunœil","Faustine Ormelin","Edgar Ardoisier","Bertille Cressonnier","Table 1, siège 1","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","Personne choisie :","Gaspard Marbrelle","Assigner","Retirer la réservation","Retirer le titre","Oublier le choix","Menu de","Basile Lanterneau","Déplacer","Échanger","Retirer du tour","Déplacer sa réservation","Exclure","Mettre en évidence","Ouvrir la fiche"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"ee36c3b91d3c64a086d46e111a3240c1fb5dba0bc5f59f3c4aca3d185f3b93d9","etape":"8","etat":"4d37d30748068354162752743c26aa7e0ab00d6e9b2aa1996b6f9fb410848280","facteurEchelle":1,"fichier":"10-diagnostic","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"316870a5b600b76b054b972d27096c8a8bc00e8ae70098df30bb114a8cd150df"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":5.29,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":7.05,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":7.34,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":7.98,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Modifier","Lecture","Défaire","Grande démonstration 1","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Diagnostic","écart d'itinéraire maximal : plancher 1, au moins 24 mobiles finissent sous leur plafond a priori","aucune relance ne le réduit : il tient aux tables pleines dont les capacités ou les ancrages diffèrent","plafond a priori 24 rencontres pour 4 ancrés","seules les capacités et les réservations le changent : aucune relance ne le dépasse","plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles","seules les capacités et les réservations le changent : aucune relance ne le dépasse","99 retours imposés par 33 ancrages","les réservations les imposent : ils ne comptent pas parmi les retours que choisit le moteur"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"3b02f6576c9ba053182280869141b2bd68809d140be1fd7ced68e90221c47520","etape":"9","etat":"78c47299a88ae35b3dc4c09bededdd6e0e9dc695a35d313d74f46c9cbede5573","facteurEchelle":1,"fichier":"11-propositions","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"f1e18d3337e3d9ebae04b1b4f2b2ec85f10a727175f075621bd1cdecc6eb9387"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Propositions","Ordre du classement, chaque critère au plus faible d'abord :","écart au plafond a priori",", puis","excédent de collisions",", puis","collisions cumulées",", puis","rencontres répétées",", puis","redondance d'appartenance",", puis","l'identifiant croissant",".","Propositions classées","Rang","Proposition","Personnes distinctes rencontrées, minimum : tous","Personnes distinctes rencontrées, minimum : mobiles","Personnes distinctes rencontrées, minimum : ancrés","Collisions cumulées (paire et tour)","Paires distinctes de même appartenance","Excédent de collisions (retrouvailles au-delà de la première)","Rencontres répétées choisies (paires)","Rencontres d'une même paire, au plus","Retours choisis à une table déjà occupée","Appartenances rencontrées A(p), minimum","Redondance d'appartenance, somme Σ r(p)","Taux de diversité d(p), minimum","Plus grand manque (personnes)","Personnes à qui il en manque","Écart d'itinéraire maximal (personnes)","Écart au plafond a priori maximal (personnes)","Certificat","Imposé par les réservations","Commandes","Placement retenu","3","Tel que la proposition n° 3.","11","11","11","26","15","11","30","3","11","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","1","3","retenue","11","11","11","26","15","11","30","3","11","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","2","1","11","11","11","27","15","12","19","3","16","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","3","2","11","11","11","33","15","18","54","3","16","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","4","5","10","10","10","20","15","5","22","3","12","3","83","0,27","1","1","1","1","atteignabilité inconnue","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","5","4","10","10","11","31","14","17","46","3","17","3","82","0,30","1","4","0","1","atteignabilité inconnue","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","Le numéro de la proposition, donné à sa génération ; un numéro effacé ne revient jamais. Il sert de dernier départage : deux propositions égales sur tous les critères se rangent par numéro croissant.","Cible de la somme de redondance","La liste des événements montre, pour le placement retenu, la différence sans signe entre cette cible et la somme Σ r(p) de la colonne « Redondance d'appartenance ». Un nombre entier, 0 ou plus.","Effacer les propositions"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"baa328a99035a6771145a49ac8c8f8530a4f558aa26db87be66cafc3f3e0541d","etape":"11","etat":"78c47299a88ae35b3dc4c09bededdd6e0e9dc695a35d313d74f46c9cbede5573","facteurEchelle":1,"fichier":"19-ajustement","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"f31cf69b4afb247d3a1714bb994257df04afdd0114c8281869aa9de708273a38"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":13.44,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":4.78,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":15.38,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-conflit-appartenance","ratio":8.92,"role":"marque","seuil":3},{"jeton":"--couleur-contour-fort","ratio":12.54,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":15.1,"role":"marque","seuil":3},{"jeton":"--couleur-fantome","ratio":4.34,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-poignee","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.58,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-ecriture","ratio":9.1,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-conflit-appartenance","ratio":6.81,"role":"marque","seuil":3},{"jeton":"--couleur-contour-fort","ratio":11.62,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-fantome","ratio":4.1,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":15.21,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":11.39,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":11.62,"role":"marque","seuil":3},{"jeton":"--couleur-pastille-texte","ratio":14.51,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Revenir en lecture","Écriture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Plan","Placement retenu","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","9","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2.","Gaspard Marbrelle","3.","Ysaline Sablonnet","4.","Anselme Givrecœur","5.","Côme Fenouillet","6.","Apolline Coudraie","7.","Brunœil","8.","Faustine Ormelin","9.","Edgar Ardoisier","10.","Bertille Cressonnier","1.","Basile Lanterneau","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","Gaspard Marbrelle","déplace dans le placement retenu","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 1 ancré et 11 mobiles","2 retours imposés par 1 ancrage","Listes de travail","Titres de place non pourvus","table 2 : titre","animation","non pourvu","Titres pressentis sans réservation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Personne en attente à ce tour."],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"b08a852c1d9e924ff9b3415806c8abfae0a47ba4d33934c8a4f24dea95511baa","etape":"11","etat":"20666d70d9b2503335efc1f2cece5453a199ab77f4864835baf4bccf5aff17d5","facteurEchelle":1,"fichier":"20-mode-lecture","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"b5ef6ac5383188a47783bb46834ccb49fb545dc6442f1d35304cd458940c6c18"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":14.26,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":13.44,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":5.29,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":15.38,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-conflit-appartenance","ratio":8.92,"role":"marque","seuil":3},{"jeton":"--couleur-contour-fort","ratio":12.54,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":15.1,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":15.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-poignee","ratio":7.58,"role":"marque","seuil":3},{"jeton":"--couleur-selection","ratio":4.66,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.58,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-cadre-lecture","ratio":7.34,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-conflit-appartenance","ratio":6.81,"role":"marque","seuil":3},{"jeton":"--couleur-contour-fort","ratio":11.62,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":15.21,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":14.62,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":11.62,"role":"marque","seuil":3},{"jeton":"--couleur-pastille-texte","ratio":14.51,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-selection","ratio":4.18,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Modifier","Lecture","Défaire","Soirée d’essai","Brouillon","Proposé","Retenu","Bloqué","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Plan","Placement retenu","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","7","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2.","Ysaline Sablonnet","3.","Anselme Givrecœur","4.","Côme Fenouillet","5.","Apolline Coudraie","6.","Brunœil","7.","Faustine Ormelin","8.","Edgar Ardoisier","9.","Bertille Cressonnier","10. (libre)","1.","Gaspard Marbrelle","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3.","Basile Lanterneau","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","Table 2, siège 4","L'événement est ouvert en lecture : « Modifier » permet de le changer.","Modifier","Fermer","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 1 ancré et 11 mobiles","2 retours imposés par 1 ancrage","Listes de travail","Titres de place non pourvus","table 2 : titre","animation","non pourvu","Titres pressentis sans réservation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Personne en attente à ce tour.","Table 2, siège 4","4. (libre)","Choisissez d'abord une personne : dans la réserve, dans la liste des pressentis, ou par « Déplacer » dans son menu.","Portée","Tour 1","Tous les tours","Libellé du titre","Assigner","Réserver","Donner un titre","Désigner"],"theme":"clair","versionReelle":"2026.10.05.01"} +],"format":1,"pilote":{"cadre":{"hauteur":700,"largeur":1366},"facteurEchelle":1,"mode":"chromium-headless-shell","navigateur":"chromium","versionNavigateur":"153.0.8010.12"},"reportees":[ +{"etape":"10","fichier":"12-qualite-profil","raison":"iteration5"}, +{"etape":"10","fichier":"13-qualite-collisions","raison":"iteration5"}, +{"etape":"10","fichier":"14-qualite-paires","raison":"iteration5"}, +{"etape":"10","fichier":"15-pas-de-matrice","raison":"iteration5"}, +{"etape":"10","fichier":"16-minimum-atteint","raison":"iteration5"}, +{"etape":"10","fichier":"17-comparaison-manque","raison":"iteration5"}, +{"etape":"10","fichier":"18-comparaison-escalier","raison":"iteration5"}, +{"etape":"11","fichier":"21-historique","raison":"iteration5"} +]} diff --git a/src/demo/catalogue.long.test.js b/src/demo/catalogue.long.test.js index 165470b..56b74b1 100644 --- a/src/demo/catalogue.long.test.js +++ b/src/demo/catalogue.long.test.js @@ -51,10 +51,18 @@ const CSV_DE_DEMONSTRATION = new Set([ 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. +// Le manifeste des captures, que make captures écrit : il relève les textes +// visibles du parcours photographié, qui joue les démonstrations livrées. +const MANIFESTE_DES_CAPTURES = join('doc', 'captures', 'manifeste.json'); + +// Vrai pour les listes du générateur, pour un fichier que +// scripts/engendrer_livrables.js en engendre, et pour le manifeste des +// captures. const porteLesNomsParConstruction = (chemin) => - chemin === LISTES || chemin.startsWith(DEMONSTRATIONS_LIVREES) || CSV_DE_DEMONSTRATION.has(chemin); + chemin === LISTES || + chemin.startsWith(DEMONSTRATIONS_LIVREES) || + CSV_DE_DEMONSTRATION.has(chemin) || + chemin === MANIFESTE_DES_CAPTURES; // Répertoires hors balayage, à toute profondeur : .git, les dépendances, les // caches des outils, et les sorties de construction, qui portent une copie @@ -215,6 +223,7 @@ describe('noms forgés : balayage du projet (§ 15.6)', () => { [join('src', 'demo', 'livrees', 'grande.gtt.json'), patronyme], [join('exemples', 'participants_demo_petite.csv'), patronyme], [join('exemples', 'participants_demo_grande.csv'), patronyme], + [join('doc', 'captures', 'manifeste.json'), patronyme], ]; // Un fichier sous chaque répertoire exclu, à la racine et plus profond ; // chacun porte un nom forgé. diff --git a/src/interface/parcours.navigateur.test.js b/src/interface/parcours.navigateur.test.js index 9cc5c16..716082e 100644 --- a/src/interface/parcours.navigateur.test.js +++ b/src/interface/parcours.navigateur.test.js @@ -11,8 +11,10 @@ // laissé, affirme sa condition avant la suivante, et finit sur un collecteur // d'erreurs vide. // -// Le parcours suit les captures du § 19.3 qui relèvent de l'itération — 01, -// 01b, 02 à 09, 11, 19, 20 —, puis l'ordre de lecture du § 2.1 sur +// Le parcours est le parcours photographié (§ 19.1, § 19.3) : il suit les +// captures du § 19.3 que l'itération photographie — 01, 01b, 02, 02b, 03 à +// 07, 07b, 08 à 10, les deux 07a sur la grande démonstration que 10 a +// chargée, 11, 19, 20 —, puis l'ordre de lecture du § 2.1 sur // l'événement retenu, les fenêtres — aucune pendant un geste non // destructeur, une qui nomme ce qui part pour chaque geste qui détruit —, // enfin chaque famille de geste modifiant en lecture puis sur un plan @@ -21,24 +23,39 @@ // que celle de l'application. Aucun délai fixe : chaque étape attend un // état du document (§ 19.3). // +// Une épreuve qui porte une capture finit, ses conditions affirmées, par +// photographier (test/navigateur/captures.js) ; une épreuve qui continue +// au-delà de l'écran photographié se coupe en deux. Rien de la machine +// n'entre dans une région photographiée : la provenance « documentation » +// tient lieu de version (§ 18.6), l'exécuteur direct annonce quatre cœurs, +// l'horloge est pilotée, la feuille qui annule transitions et animations +// est posée (test/navigateur/feuilles.js). La dernière épreuve du fichier +// publie les captures d'un bloc sous make captures (§ 19.5), et les +// compare au manifeste sinon (§ 19.7). +// // Le projet navigateur emploie expect, et test et describe de Vitest // (§ 14.8). import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; -import { cdp } from 'vitest/browser'; +import { cdp, commands } from 'vitest/browser'; import { flushSync } from 'svelte'; import { bilanRemplacement, cleNormalisee } from '../application/csv.js'; +import { creerExecuteurDirect } from '../application/generation.js'; import { TABLES_APPLICATION } from '../application/libelles.js'; import { nomAffiche } from '../application/personnes.js'; import { etatPlaces } from '../application/places.js'; import { creerTraduction } from '../application/traduction.js'; +import { versionVisible } from '../application/version.js'; import { apercevoir } from '../csv/apercu.js'; import { decoder } from '../csv/encodage.js'; +import { decouper } from '../csv/lecture.js'; import { CATALOGUE } from '../demo/catalogue.js'; +import { regimeCibles } from '../geometrie/designation.js'; import { echelleGraphique } from '../geometrie/echelle.js'; -import { diametre } from '../geometrie/tables.js'; +import { diametre, geometrieTable } from '../geometrie/tables.js'; import { vueDepuisTransform } from '../geometrie/vue.js'; import { CRITERES, classer } from '../moteur/classement.js'; import { indexerPlan, nombrePlaces, normaliser } from '../moteur/configuration.js'; +import { diagnostiquer } from '../moteur/diagnostic.js'; import { mesurer } from '../moteur/indicateurs.js'; import { plafondsAPriori } from '../moteur/plafond.js'; import { rechercher } from '../moteur/recherche.js'; @@ -48,8 +65,11 @@ import { lireJournal } from '../stockage/journal.js'; import { SUFFIXES } from '../stockage/noms.js'; import { planDepuisFichier } from '../stockage/placements.js'; import { VERSION } from '../version.genere.js'; +import urlCp1252 from '../../exemples/participants_cas_limites_cp1252.csv?url'; +import { CADRE, capturesPrises, cloreCaptures, modeFourni, photographier } from '../../test/navigateur/captures.js'; import { NOM_CAS_LIMITES, attendre, centre, octetsCasLimites } from '../../test/navigateur/ecrans.js'; import { fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js'; +import { poserFeuille } from '../../test/navigateur/feuilles.js'; import { monterApp } from '../../test/navigateur/montage.js'; import { visible } from '../../test/navigateur/rendu.js'; import { ENCODAGES, SEPARATEURS } from './import/modele.js'; @@ -64,6 +84,12 @@ const NOM_SOIREE = 'Soirée d\u{2019}essai'; const GRAINE = 4242; const ARRET = 300; +// Le CSV des cas limites enregistré en windows-1252 (§ 10.3, capture 02b). +const NOM_CP1252 = 'participants_cas_limites_cp1252.csv'; + +// Ce qui tient lieu de version dans une construction de documentation. +const MARQUE_PROVENANCE = versionVisible('documentation', VERSION.affichee, t); + // La personne que l'ordre de lecture ajoute : un nom inventé. const AJOUTEE = { nom: 'Ombrelle', prenom: 'Iris' }; @@ -100,15 +126,24 @@ const DUREE = 120_000; // Le montage du parcours, que chaque épreuve reprend où la précédente l'a // laissé ; les relevés des fenêtres. let m = null; +let retirerFeuille = null; const gestesSurveilles = new Set(); const fenetresVues = []; +// Les épreuves du fichier qui ont échoué, test ou crochet : la dernière +// épreuve refuse de publier tant qu'une seule y figure. +const epreuvesEnEchec = []; // Ce que les étapes se transmettent : la personne désignée, la table et la // proposition retenue, la charge d'avant l'ajustement de 19. const memoire = {}; +// En écriture, le dossier d'attente part vide : le dépôt d'un passage +// interrompu ne se publie jamais avec celui-ci. beforeAll(async () => { - await fixerCadre(1366, 700); - m = await monterApp(); + await fixerCadre(CADRE.largeur, CADRE.hauteur); + retirerFeuille = poserFeuille(); + await commands.requetesHorsBoucle(); + if (modeFourni() === 'ecrire') await commands.viderAttente(); + m = await monterApp({ provenance: 'documentation', executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) }); }); // La capture d'un échec prend pour nom de fichier le titre de son groupe et @@ -118,7 +153,10 @@ beforeAll(async () => { // cette limite. const TITRE_MAX_OCTETS = 200; -beforeEach(({ task }) => { +beforeEach(({ task, onTestFailed }) => { + onTestFailed(() => { + epreuvesEnEchec.push(task.name); + }); const titre = `${task.suite?.name ?? ''} ${task.name}`; expect(new TextEncoder().encode(titre).length, titre).toBeLessThanOrEqual(TITRE_MAX_OCTETS); }); @@ -129,6 +167,7 @@ afterEach(() => { afterAll(async () => { m?.demonter(); + retirerFeuille?.(); window.scrollTo(0, 0); }); @@ -140,7 +179,8 @@ const etat = () => m.seance.etat(); const charge = () => etat().charge; const revision = () => etat().evenement.revision; const base = () => etat().evenement.base; -const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); +const plat = (texte) => texte.replace(/\s+/g, ' ').trim(); +const texteDe = (element) => plat(element.textContent); // Le texte de l'état et du journal de l'événement ouvert, et sa révision. function fichiers() { @@ -249,6 +289,54 @@ async function glisserVers(depuis, vers) { await images(3); } +// Ferme l'aperçu d'import ouvert, puis ouvre celui du fichier donné ; rend +// l'aperçu, son nom de fichier affiché. +async function ouvrirApercu(nom, octets) { + if (dans('[data-apercu]') !== null) { + await cliquer('[data-apercu] [data-commande="fermerApercu"]'); + await attendre('l’aperçu fermé', () => dans('[data-apercu]') === null); + } + m.fs.choisirFichierAImporter = async () => ({ nom, octets }); + await cliquer('[data-section="import"] [data-commande="choisirFichier"]'); + return attendre(`l’aperçu de ${nom}`, () => dans('[data-apercu] [data-fichier]')?.textContent.trim() === nom && dans('[data-apercu]')); +} + +// Les crans de « Zoom + » au-delà du premier zoom où chaque table de la +// grande démonstration passe au régime des sièges (capture 07a). À ce +// premier zoom, une chaise mesure environ 18 px : le trait de la chaise 3, +// d'épaisseur fixe à l'écran, longe celui du plateau qu'elle touche sur une +// bande où tombent la plupart des points que le relevé des marques +// échantillonne (captures.js), et la marque s'y mesure contre un trait de sa +// propre couleur. Deux crans plus haut, la bande ne retient plus qu'une +// minorité de ces points. +const CRANS_AU_DESSUS = 2; + +// Le zoom lu sur le groupe de la vue du plan. +const zoomLu = () => vueDepuisTransform(dans('[data-vue]').getAttribute('transform')).k; + +// Le régime des cibles de chaque table montrée, au zoom lu. +const regimes = () => etatMontre().tables.map((table) => regimeCibles(zoomLu(), geometrieTable(table, table.capacite))); + +// Clique la commande de zoom jusqu'à ce que chaque table soit au régime +// voulu, en au plus crans clics. +async function zoomerJusqua(regime, commande, crans = 40) { + for (let cran = 0; regimes().some((r) => r !== regime); cran += 1) { + expect(cran, `crans de ${commande} jusqu’au régime ${regime}`).toBeLessThan(crans); + await cliquer(`[data-commande="${commande}"]`); + await images(2); + } +} + +// Ramène en haut chaque ancêtre qui défile d'un élément. +function enHaut(element) { + for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) { + if (ancetre.scrollHeight > ancetre.clientHeight) ancetre.scrollTop = 0; + } +} + +// L'élément du document qu'atteint le point (x, y) de la fenêtre. +const atteintAuPoint = ({ x, y }) => document.elementFromPoint(x, y); + // Ouvre une section de l'événement par sa navigation. async function ouvrirSection(nom) { await cliquer(`[data-commande="section"][data-cible="${nom}"]`); @@ -421,7 +509,7 @@ async function refusPres(porteur, code, remede, geste) { // --- Le parcours (§ 2.1, § 19.3) ------------------------------------------------------ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE }, () => { - test('01 le répertoire vide : le catalogue offert, la version, le chemin ; « Soirée d’essai » créée s’ouvre en lecture', async () => { + test('01 le répertoire vide : le catalogue offert, la marque de provenance à la place de la version, le chemin', async () => { await attendre('le premier écran', () => dans('[data-ecran="accueil"] [data-dossier-travail]')); expect(tous('[data-evenement]')).toHaveLength(0); const offertes = tous('[data-demonstration]'); @@ -431,12 +519,17 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(texteDe(entree)).toBe(CATALOGUE[rang].nom); expect(visible(entree)).toBe(true); }); - expect(dans('[data-version]').textContent).toContain(VERSION.affichee); - expect(dans('[data-bandeau]').textContent).toContain(VERSION.affichee); + for (const porteur of ['[data-version]', '[data-bandeau]']) { + expect(dans(porteur).textContent, porteur).toContain(MARQUE_PROVENANCE); + expect(dans(porteur).textContent, porteur).not.toContain(VERSION.affichee); + } const chemin = dans('[data-dossier-travail]').textContent; expect(chemin.length).toBeGreaterThan(0); expect(chemin).toBe(etat().racine.chemin); + await photographier(m, '01-premier-ecran', { condition: ['[data-dossier-travail]', '[data-version]', '[data-demonstration]'] }); + }); + test('01 « Soirée d’essai » créée s’ouvre en lecture', async () => { await taper(dans('[data-ecran="accueil"] input[data-champ="nom"]'), NOM_SOIREE); await cliquer('[data-ecran="accueil"] [data-commande="creer"]'); await attendre('l’événement ouvert', () => dans('[data-ecran="evenement"]') && etat().evenement !== null); @@ -445,7 +538,7 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(charge().participants).toHaveLength(0); }); - test('01b une modification refusée avant « Modifier », à côté de son geste ; acceptée après ; le cadre visible', async () => { + test('01b une modification refusée en lecture, à côté de son geste, avec « Modifier » pour remède ; le cadre de lecture', async () => { await ouvrirSection('tables'); const avant = fichiers(); expect(dans('[data-mode]').dataset.marque).toBe('cadreLecture'); @@ -454,7 +547,12 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(document.querySelectorAll(FENETRE)).toHaveLength(0); await rienEcrit(avant, 'créer une table en lecture'); expect(charge().tables).toHaveLength(0); + await photographier(m, '01b-passer-en-ecriture', { + condition: ['[data-place-fixe] [data-commande="modifier"]', '[data-refus="MODE_LECTURE"]', '[data-mode-libelle="cadre"]'], + }); + }); + test('01b « Modifier » : l’écriture et son cadre ; la même modification passe', async () => { await cliquer('[data-place-fixe] [data-commande="modifier"]'); await attendre('l’écriture', () => etat().mode === 'ecriture' && dans('[data-place-fixe] [data-commande="lecture"]')); const cadre = dans('[data-mode]'); @@ -486,9 +584,32 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(apercu.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1); expect(apercu.querySelector('[data-commande="importer"]')).not.toBeNull(); await rienEcrit(avant, 'l’aperçu'); + await photographier(m, '02-import-apercu', { condition: ['[data-apercu] [data-encodage]', '[data-apercu] [data-separateur]'] }); + }); + + test('02b l’aperçu d’un CSV en windows-1252 : l’encodage nommé, chaque caractère accentué intact', async () => { + const octets = new Uint8Array(await (await fetch(urlCp1252)).arrayBuffer()); + const avant = fichiers(); + const apercu = await ouvrirApercu(NOM_CP1252, octets); + const { texte, encodage } = decoder(octets); + expect(encodage).toBe('windows-1252'); + expect(texteDe(apercu.querySelector('[data-encodage]'))).toBe(t(ENCODAGES.get(encodage))); + // Les cellules hors ASCII du fichier, décodées par l'analyseur : celles + // que l'aperçu montre y sont entières ; aucun caractère de remplacement, + // aucune séquence UTF-8 lue en windows-1252. + const accentuees = decouper(texte, apercevoir(texte).separateur) + .enregistrements.flat() + .map((cellule) => cellule.trim()) + .filter((cellule) => /[^\u{0}-\u{7F}]/u.test(cellule)); + const montrees = accentuees.filter((cellule) => apercu.textContent.includes(cellule)); + expect(montrees.length, accentuees.join(' | ')).toBeGreaterThan(0); + expect(apercu.textContent).not.toMatch(/\u{FFFD}|\u{C3}[\u{80}-\u{BF}]/u); + await rienEcrit(avant, 'l’aperçu en windows-1252'); + await photographier(m, '02b-import-cp1252', { condition: ['[data-apercu] [data-encodage]', '[data-apercu] [data-fichier]'] }); }); test('03 le rapport : une entrée, les lignes valides comptées, le groupe fondu et la ligne refusée', async () => { + await ouvrirApercu(NOM_CAS_LIMITES, await octetsCasLimites()); const r = revision(); await cliquer('[data-apercu] [data-commande="importer"]'); const rapport = await attendre('le rapport', () => dans('[data-rapport]')); @@ -499,6 +620,7 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(rapport.querySelectorAll('[data-fusion]').length).toBeGreaterThanOrEqual(1); expect(rapport.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1); memoire.importes = ajoutes; + await photographier(m, '03-import-rapport', { condition: ['[data-rapport] [data-compte="ajoutes"]'] }); }); test('04 la grille : le compte égal à l’import, une personne exclue ; trier, filtrer ; modifier, exclure, réintégrer sans fenêtre', async () => { @@ -571,6 +693,14 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE await uneEntree(r, 'réintégrer'); }); expect(charge().participants.find((p) => p.id === cible.id).exclu).toBe(false); + // La capture montre le haut de la grille, son tri et son filtre : le + // focus de la dernière cellule saisie part, la grille revient en haut. + document.activeElement?.blur(); + enHaut(section().querySelector('[role="grid"]')); + await images(2); + await photographier(m, '04-grille-participants', { + condition: ['[data-section="participants"] [data-champ="filtre"]', '[data-section="participants"] [data-commande="trier"][data-cible="nom"]'], + }); }); test('05 deux tables à 8, l’une au défaut, l’autre surchargée à 8 : leurs états diffèrent à valeur égale', async () => { @@ -595,17 +725,25 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(visible(etatDe(premiere.id))).toBe(true); expect(visible(etatDe(seconde.id))).toBe(true); expect(texteDe(etatDe(premiere.id))).not.toBe(texteDe(etatDe(seconde.id))); + await photographier(m, '05-tables-etat', { + condition: [`[data-section="tables"] [data-table="${premiere.id}"] [data-etat-table]`, `[data-section="tables"] [data-table="${seconde.id}"] [data-etat-table]`], + }); }); - test('06 l’énoncé d’un changement de défaut, son total égal au moteur ; étape 5, le nombre de tours', async () => { + test('06 l’énoncé d’un changement de défaut, chiffré avant toute application : rien d’écrit', async () => { const avant = fichiers(); await saisir(dans('[data-section="tables"] input[data-champ="siegesParDefaut"]'), '10'); const enonce = await attendre('l’énoncé', () => dans('[data-enonce="defaut"]')); expect(visible(enonce)).toBe(true); await rienEcrit(avant, 'l’énoncé'); - const phrase = [...enonce.querySelectorAll('[data-phrase]')].map(texteDe).join(' '); + memoire.phrase = [...enonce.querySelectorAll('[data-phrase]')].map(texteDe).join(' '); + expect(memoire.phrase).toMatch(/(\d+)\s*\u{2192}\s*(\d+)/u); + await photographier(m, '06-defaut-consequence', { condition: ['[data-enonce="defaut"]', '[data-enonce="defaut"] [data-commande="appliquer"]'] }); + }); + + test('06 appliquer le défaut : le total annoncé égal au moteur ; étape 5, le nombre de tours', async () => { + const { phrase } = memoire; const total = phrase.match(/(\d+)\s*\u{2192}\s*(\d+)/u); - expect(total, phrase).not.toBeNull(); await sansFenetre('appliquerDefaut', async () => { const r = revision(); await cliquer('[data-enonce="defaut"] [data-commande="appliquer"]'); @@ -645,6 +783,54 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE const mention = [...dans('[data-surface]').querySelectorAll('p')].find((p) => texteDe(p) === t('plan.echelle.mention')); expect(mention).toBeDefined(); expect(visible(mention)).toBe(true); + await photographier(m, '07-plan-tables', { condition: ['[data-surface] [data-marque="echelle"]', '[data-plan]'] }); + }); + + // Étape 5 : retirer un tour qui porte une réservation « tour désigné » + // l'annonce avant d'écrire ; la réservation posée pour l'annonce part + // ensuite par « Défaire ». + test('07b retirer un tour : l’annonce nomme la réservation détruite, avant toute écriture', async () => { + await ouvrirPlan(); + const selecteurTour = dans('[data-champ="tour"]'); + selecteurTour.value = '3'; + selecteurTour.dispatchEvent(new Event('change', { bubbles: true })); + await attendre('le tour 3 montré', () => m.ui.etat().tour === 3); + await images(2); + await regimeSieges(); + let r = revision(); + const reservee = await choisir('[data-reserve]'); + const libre = chaiseVue((c) => c.occupant === null && c.reservation === null && c.titre === null); + await cliquerPointeur(chaise(libre.table, libre.siege)); + await appuyer(actionPlace('reserver'), 'réserver au tour 3'); + await uneEntree(r, 'réserver au tour 3'); + const avantAnnonce = charge(); + expect(avantAnnonce.reservations).toContainEqual(expect.objectContaining({ participant: reservee, portee: 'tour', tour: 3 })); + selecteurTour.value = '1'; + selecteurTour.dispatchEvent(new Event('change', { bubbles: true })); + await attendre('le tour 1 montré', () => m.ui.etat().tour === 1); + + await ouvrirSection('tables'); + const avant = fichiers(); + await saisir(dans('[data-section="tables"] input[data-champ="tours"]'), '2'); + const fenetre = await attendre('l’annonce', () => dans('dialog[open]')); + const numero = avantAnnonce.tables.find(({ id }) => id === libre.table).numero; + const nom = nomAffiche(avantAnnonce.participants.find(({ id }) => id === reservee)); + expect(texteDe(fenetre.querySelector('h2'))).toBe(plat(t('tables.tours.confirmation', { avant: 3, apres: 2 }))); + expect([...fenetre.querySelectorAll('li')].map(texteDe)).toContain( + plat(t('tables.tours.reservation', { nom, numero, tour: 3 })), + ); + await rienEcrit(avant, 'l’annonce'); + await photographier(m, '07b-nombre-de-tours', { condition: ['dialog[open] h2', 'dialog[open] [data-commande="renoncer"]'] }); + + await appuyer(fenetre.querySelector('[data-commande="renoncer"]'), 'renoncer'); + await attendre('l’annonce fermée', () => dans('dialog[open]') === null); + await rienEcrit(avant, 'renoncer'); + r = revision(); + await cliquer('[data-historique] [data-commande="defaire"]'); + await uneEntree(r, 'défaire la réservation'); + expect(charge().reservations).toEqual([]); + expect(charge().evenement.tours).toBe(3); + await ouvrirPlan(); }); test('08 deux animateurs — cadenas fermé et anneau, hachuré et cadenas ouvert —, leurs lignes, les deux listes de travail', async () => { @@ -708,6 +894,9 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(titres.map((b) => b.dataset.titre.split(':')[0])).toEqual([String(t2)]); expect(texteDe(titres[0])).toContain(titre); expect(tous('[data-liste-travail="pressentis"] [data-participant]').length).toBeGreaterThan(0); + await photographier(m, '08-animateurs-designes', { + condition: [`[data-siege="${designee[0].dataset.siege}"]`, `[data-siege="${titree[0].dataset.siege}"]`], + }); }); test('09 un refus sur la place, sa cause et son remède, sans fenêtre ni écriture', async () => { @@ -732,10 +921,71 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(visible(message)).toBe(true); expect(vues).toEqual([]); await rienEcrit(avant, 'le refus sur la place'); + await photographier(m, '09-refus-sur-place', { condition: [`[data-siege="${t1}:${reservee.siege}"]`] }); await oublierChoix(); }); - test('11 générer, graine écrite, compte réduit : trois propositions du moteur, dans l’ordre de sa clé ; diversité ; retenir', async () => { + // La grande démonstration, chargée depuis le premier écran : l'échelle est + // le propos (§ 19.3), et le diagnostic se lit avant toute génération. + test('10 la grande démonstration, chargée depuis le premier écran : 99 retours imposés, plafond 24, plancher d’écart, égaux au moteur', async () => { + memoire.base = base(); + await cliquer('[data-bandeau] [data-commande="liste"]'); + await attendre('le premier écran', () => dans('[data-ecran="accueil"] [data-demonstration="grande"]')); + await cliquer('[data-demonstration="grande"]'); + await attendre('la grande démonstration ouverte', () => dans('[data-ecran="evenement"]') && etat().evenement !== null && etat().evenement.base !== memoire.base); + await ouvrirSection('diagnostic'); + const diagnostic = diagnostiquer(configurationDepuisCharge(charge())); + expect(diagnostic.retoursImposes).toBe(99); + expect(diagnostic.plafondAPriori.repartition[0]).toEqual({ valeur: 24, ancres: 4, mobiles: 0 }); + expect(diagnostic.ecartItineraire?.plancher).toBe(1); + const phrases = tous('[data-section="diagnostic"] [data-phrase]').map(texteDe); + for (const attendue of [ + t('diagnostic.plafond', diagnostic.plafondAPriori.repartition[0]), + t('diagnostic.imposes', { retoursImposes: diagnostic.retoursImposes, ancragesImposantRetours: diagnostic.ancragesImposantRetours }), + t('diagnostic.ecartItineraire', diagnostic.ecartItineraire), + ]) { + expect(phrases).toContain(plat(attendue)); + } + await photographier(m, '10-diagnostic', { condition: ['[data-section="diagnostic"] [data-phrase]'] }); + }); + + // Le seuil de CIBLE_MIN_PX (§ 7.3, § 19.10), au-dessus puis au-dessous, + // sur la grande démonstration : au zoom ajusté de la soirée construite, ses + // tables restent au régime des sièges jusqu'au zoom minimal, la moitié du + // zoom ajusté. Le point du centre d'une chaise atteint la chaise au + // régime des sièges, la table au régime de la table. + test('07a les sièges cibles, sur la grande démonstration : zoomée jusqu’au régime des sièges, le centre d’une chaise atteint la chaise', async () => { + await ouvrirPlan(); + await zoomerJusqua('sieges', 'zoomer'); + for (let cran = 0; cran < CRANS_AU_DESSUS; cran += 1) await cliquer('[data-commande="zoomer"]'); + await images(2); + const vue = chaiseVue(() => true); + const disque = chaise(vue.table, vue.siege).querySelector('circle'); + const point = await centre(disque); + expect(atteintAuPoint(point)?.closest('[data-siege]'), 'l’élément au centre de la chaise').toBe(chaise(vue.table, vue.siege)); + memoire.centreChaise = { table: vue.table, x: Number(disque.getAttribute('cx')), y: Number(disque.getAttribute('cy')) }; + await photographier(m, '07a-cibles-sieges', { condition: [`[data-siege="${vue.table}:${vue.siege}"]`] }); + }); + + test('07a la table cible, sur la grande démonstration : au zoom ajusté, régime de la table, le même point atteint la table, aucune chaise', async () => { + await cliquer('[data-commande="ajuster"]'); + await images(2); + expect(regimes().every((regime) => regime === 'table'), regimes().join(' ')).toBe(true); + expect(tous('[data-plan] [data-siege]')).toHaveLength(0); + const { table, x, y } = memoire.centreChaise; + const point = new DOMPoint(x, y).matrixTransform(dans('[data-vue]').getScreenCTM()); + const atteint = atteintAuPoint(point); + expect(atteint?.closest('[data-siege]') ?? null).toBeNull(); + expect(atteint?.closest('[data-table]')?.dataset.table, 'la table au point de sa chaise').toBe(String(table)); + await photographier(m, '07a-cibles-tables', { condition: [`[data-plan] [data-table="${table}"]`] }); + }); + + test('11 rouvrir la soirée ; générer, graine écrite, compte réduit : trois propositions du moteur, dans l’ordre de sa clé ; diversité ; retenir', async () => { + await cliquer('[data-bandeau] [data-commande="liste"]'); + await cliquer(`[data-evenement="${memoire.base}"] [data-commande="ouvrir"]`); + await attendre('la soirée rouverte', () => dans('[data-ecran="evenement"]') && etat().evenement?.base === memoire.base); + await cliquer('[data-place-fixe] [data-commande="modifier"]'); + await attendre('l’écriture', () => etat().mode === 'ecriture'); await ouvrirSection('generation'); await taper(dans('[data-section="generation"] input[data-champ="graine"]'), String(GRAINE)); let r = revision(); @@ -783,18 +1033,30 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE expect(charge().retenu.proposition).toBe(ordre[0]); expect(charge().evenement.etat).toBe('retenu'); await attendre('la ligne du retenu', () => section.querySelector(`tr[data-retenu="${ordre[0]}"]`)); + await photographier(m, '11-propositions', { condition: ['[data-section="propositions"] [data-ordre]'], graine: GRAINE }); }); - test('19 un ajustement : le glissement dit lequel des deux gestes est en cours, une entrée, le retenu changé', async () => { + test('19 un ajustement en cours : le glissement dit lequel des deux gestes, celui du placement retenu', async () => { await ouvrirPlan(); memoire.avantAjustement = serialiserCharge(charge()); const [t1, t2] = charge().tables.map(({ id }) => id); const depuis = chaiseOccupee(t1); - const vers = chaiseLibre(t2); - const r = revision(); + // La chaise libre de t2 la plus à gauche : le libellé du fantôme, à + // droite du pointeur, reste dans le cadre du plan. + const libres = chaisesMontrees().filter((c) => c.table === t2 && c.occupant === null && c.reservation === null && c.titre === null); + expect(libres.length).toBeGreaterThan(0); + const gauche = (c) => chaise(t2, c.siege).getBoundingClientRect().left; + const vers = libres.reduce((a, b) => (gauche(b) < gauche(a) ? b : a)); + memoire.ajustement = { depuis, vers, revision: revision() }; await presserEtBouger(await centre(chaise(t1, depuis.siege)), await centre(chaise(t2, vers.siege))); const etatDonnees = await attendre('le libellé de l’état des données', () => dans('[data-fantome] [data-partie="etat"]')); expect(texteDe(etatDonnees)).toBe(t('plan.fantome.retenu')); + await photographier(m, '19-ajustement', { condition: ['[data-fantome] [data-partie="etat"]'], graine: GRAINE }); + }); + + test('19 le dépôt : une entrée qui nomme la personne, le retenu changé', async () => { + const [, t2] = charge().tables.map(({ id }) => id); + const { depuis, vers, revision: r } = memoire.ajustement; const point = await centre(chaise(t2, vers.siege)); await pointer('relacher', point.x, point.y); const libelle = await uneEntree(r, 'ajuster'); @@ -804,12 +1066,12 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE }); // Glisser une personne, une table, tirer une poignée, poser une réservation. - test('20 la lecture : quatre gestes du plan, empreinte inchangée ; « Défaire » refusé avec « Modifier », puis rend 19', async () => { + test('20 la lecture : quatre gestes du plan, empreinte inchangée ; le cadre, son libellé et le bandeau', async () => { await cliquer('[data-place-fixe] [data-commande="lecture"]'); await attendre('la lecture', () => etat().mode === 'lecture'); expect(dans('[data-mode]').dataset.marque).toBe('cadreLecture'); expect(visible(dans('[data-mode-libelle="cadre"]'))).toBe(true); - expect(dans('[data-bandeau]').textContent).toContain(VERSION.affichee); + expect(dans('[data-bandeau]').textContent).toContain(MARQUE_PROVENANCE); const empreinte = serialiserCharge(charge()); const avant = fichiers(); const [t1, t2] = charge().tables.map(({ id }) => id); @@ -831,7 +1093,12 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE await rienEcrit(avant, 'les quatre gestes en lecture'); expect(dans(`[data-table="${t2}"]`).getAttribute('transform')).toBeNull(); for (const t of etatMontre().tables) expect(tous(`[data-siege^="${t.id}:"]`), `table ${t.numero}`).toHaveLength(t.capacite); + await photographier(m, '20-mode-lecture', { condition: ['[data-mode-libelle="cadre"]', '[data-mode-libelle="bandeau"]'], graine: GRAINE }); + }); + test('20 « Défaire » en lecture : refusé avec « Modifier » ; en écriture, rend l’état d’avant 19', async () => { + expect(etat().mode).toBe('lecture'); + const avant = fichiers(); // « Défaire » en lecture : refusé à côté de lui, « Modifier » pour remède. const defaire = dans('[data-historique] [data-commande="defaire"]'); await appuyer(defaire, 'défaire en lecture'); @@ -1266,3 +1533,19 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', { await rienEcrit(avant, 'bloqué, regarder'); }); }); + +// --- Les captures d'écran du parcours (§ 19.5, § 19.7) ---------------------------------------- + +// La dernière épreuve du fichier. Une épreuve du fichier en échec, même +// après sa photographie ou sans capture, la fait échouer en la nommant, et +// rien n'est publié (§ 19.1). En écriture, elle publie d'un bloc : la +// publication refuse quand une étape déclarée n'a rien déposé, et jette le +// dépôt dans tous les cas. En vérification, chaque étape déclarée a été +// comparée à son entrée du manifeste, qui n'en porte aucune autre. Zéro +// capture n'est jamais un succès (§ 19.7). +describe('les captures d’écran du parcours (§ 19.5, § 19.7)', () => { + test('chaque capture déclarée photographiée : publiées d’un bloc en écriture, conformes au manifeste en vérification', async () => { + const { declarees } = await cloreCaptures({ echecs: epreuvesEnEchec, racineNeutre: m?.seance.etat().racine?.chemin ?? null }); + expect([...capturesPrises()].sort()).toEqual([...declarees].sort()); + }); +}); diff --git a/src/interface/transversal.navigateur.test.js b/src/interface/transversal.navigateur.test.js index 6567596..8644a28 100644 --- a/src/interface/transversal.navigateur.test.js +++ b/src/interface/transversal.navigateur.test.js @@ -5,7 +5,9 @@ // § 19.11, § 12.7, § 7.6, § 13.1, § 2, § 19.2, § 8.4, § 18.6) : langue // témoin, erreurs par leur libellé, contrastes et marques dans les deux // thèmes, aucune atténuation, jamais la seule couleur, teinte réservée, aucun -// emoji, balisage littéral, bandeau, formulaires, transitions annulées. +// emoji, balisage littéral, bandeau, formulaires, transitions annulées ; +// puis le mécanisme des captures d'écran (§ 19.4, § 19.5, § 19.7, § 19.11), +// chaque contrôle montré en train d'échouer sur un dossier d'épreuve. // // Un parcours unique, en langue témoin, au cadre de 1 366 × 700, atteint // chaque écran de test/navigateur/ecrans.js et y fait ses relevés, une fois @@ -20,6 +22,8 @@ // Le projet navigateur emploie expect, et test et describe de Vitest // (§ 14.8). import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { commands } from 'vitest/browser'; +import { creerExecuteurDirect } from '../application/generation.js'; import { TABLES_APPLICATION } from '../application/libelles.js'; import { nomAffiche } from '../application/personnes.js'; import { etatPlaces } from '../application/places.js'; @@ -44,17 +48,26 @@ import { ouvrirEvenement, } from '../../test/navigateur/ecrans.js'; import { fixerCadre, pointer } from '../../test/navigateur/entrees.js'; +import { monterApp } from '../../test/navigateur/montage.js'; import { - attenuations, - composantes, - contraste, - couleurTexte, - couleursPeintes, - designation, - fondPeintSous, - porteursDeTexte, - visible, -} from '../../test/navigateur/rendu.js'; + CADRE, + CHAMPS, + bordsPeints, + couleurDeJeton, + egales, + extrait, + releverMarques, + releverTextes, + rendu, + rgb, + cloreCaptures, + photographier, + situer, + stylesDuTheme, + texteReleve, +} from '../../test/navigateur/captures.js'; +import { poserFeuille } from '../../test/navigateur/feuilles.js'; +import { composantes, couleursPeintes, designation, sousTheme, visible } from '../../test/navigateur/rendu.js'; import { provenanceVisible } from './apropos.js'; import { TABLES_INTERFACE } from './libelles.js'; import { CONFLITS, MARQUES } from './marques.js'; @@ -64,10 +77,6 @@ const { ouverture: OUVERTURE, fermeture: FERMETURE } = MARQUE_TEMOIN; const PROVENANCE = 'test'; const THEMES = ['clair', 'sombre']; -// Seuils du § 14.5. -const SEUIL_TEXTE = 4.5; -const SEUIL_MARQUE = 3; - // Attributs annoncés : lus par l'accessibilité, ils sont du texte visible // au sens du § 14.6 (contrainte 7). const ATTRIBUTS_ANNONCES = ['aria-label', 'aria-roledescription', 'aria-description', 'aria-valuetext', 'title', 'placeholder', 'alt']; @@ -85,25 +94,6 @@ const JETON_DE_TRAME = new Map(CONFLITS.map(({ marque, jeton }) => [marque, jeto const PICTOGRAPHIQUE = /\p{Extended_Pictographic}/u; const LETTRE = /\p{L}/u; -// Une couleur en texte, pour un message. -const rgb = (c) => `rgb(${c.slice(0, 3).join(', ')}${c[3] === 1 ? '' : `, ${c[3]}`})`; -const egales = (a, b) => a.join() === b.join(); -const extrait = (texte) => { - const plat = texte.replace(/\s+/g, ' ').trim(); - return plat.length > 60 ? `${plat.slice(0, 57)}…` : plat; -}; -const rendu = (element) => element.checkVisibility({ visibilityProperty: true, opacityProperty: true }); - -// Les conteneurs désignés qui situent un élément dans son écran. -const REPERES = '[data-liste], [data-siege], [data-table], [data-pastille], [data-panneau], [data-menu], [data-section], [data-ecran], [data-bandeau]'; - -// Le nom d'un élément, suivi de celui du plus proche conteneur désigné qui -// le porte : un tspan se situe par sa liste, une pastille par sa table. -function situer(element) { - const repere = element.parentElement?.closest(REPERES) ?? null; - return repere === null ? designation(element) : `${designation(element)} dans ${designation(repere)}`; -} - // --- Les données connues de l'épreuve ------------------------------------------ /** @@ -257,297 +247,6 @@ export function fautesEmoji(racine, { titre = null } = {}) { return { fautes, lus: textes.length }; } -// --- Les contrastes des textes et les atténuations -------------------------------- - -// Amène un élément dans la fenêtre, s'il en sort. -function amener(element) { - const b = element.getBoundingClientRect(); - if (b.left < 0 || b.top < 0 || b.right > innerWidth || b.bottom > innerHeight) { - element.scrollIntoView({ block: 'center', inline: 'nearest' }); - } -} - -// Le le plus extérieur qui contient un élément SVG. -function svgExterieur(element) { - let svg = element.ownerSVGElement ?? element; - while (svg.ownerSVGElement !== null) svg = svg.ownerSVGElement; - return svg; -} - -// Feuille qui rend chaque élément atteignable le temps d'une lecture, comme -// celle de rendu.js : elementsFromPoint suit l'atteinte du pointeur. -const ATTEIGNABLE = '*, *::before, *::after { pointer-events: auto !important; }'; - -// Le point (x, y) de la fenêtre montre-t-il le dessin qui porte l'élément ? -// Dans la fenêtre, et le extérieur, ou l'un de ses descendants, parmi -// les éléments présents au point : un ancêtre qui défile rogne ce qui -// déborde de sa boîte, et le point rogné n'atteint plus le dessin. -function montreLeDessin(element, x, y) { - if (!(x >= 0 && y >= 0 && x < innerWidth && y < innerHeight)) return false; - const svg = svgExterieur(element); - const feuille = document.createElement('style'); - feuille.textContent = ATTEIGNABLE; - document.head.append(feuille); - try { - return document.elementsFromPoint(x, y).some((n) => svg.contains(n)); - } finally { - feuille.remove(); - } -} - -// Le centre de la boîte d'un élément SVG montre-t-il son dessin ? -function centreDansLeDessin(element) { - const b = element.getBoundingClientRect(); - return montreLeDessin(element, b.left + b.width / 2, b.top + b.height / 2); -} - -// Le plus faible contraste d'une couleur sur une liste de fonds. -const pire = (couleur, fonds) => - fonds.map((fond) => ({ fond, ratio: contraste(couleur, fond) })).reduce((a, b) => (b.ratio < a.ratio ? b : a)); - -// Les champs de saisie dont la valeur ou le texte indicatif se peint. -const CHAMPS = 'input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]), select, textarea'; - -/** - * Contrastes des textes rendus sous racine (§ 14.5) : chaque porteur de - * texte et chaque champ de saisie, son texte indicatif compris, sur le fond - * peint dessous ; un texte SVG par son fill sur la forme dessous. Un porteur - * que l'œil ne voit pas — couvert, rogné, de boîte vide — n'est pas mesuré. - * → { fautes, attenuations, couleurs: Map(clé → couleurs), mesures } - */ -export function releverTextes(racine) { - const fautes = []; - const fautesAttenuation = []; - const couleurs = new Map(); - let mesures = 0; - const mesurer = (cle, nom, couleurDe, fondDe, cible) => { - let couleur; - let fonds; - try { - couleur = couleurDe(); - fonds = fondDe(); - } catch (erreur) { - fautes.push(`${nom} : ${erreur.message}`); - return; - } - mesures += 1; - if (fonds.length === 0) { - fautes.push(`${nom} : aucun fond opaque dessous`); - return; - } - try { - const { fond, ratio } = pire(couleur, fonds); - if (ratio < SEUIL_TEXTE) fautes.push(`${nom} : ${ratio.toFixed(2)}:1, ${rgb(couleur)} sur ${rgb(fond)}`); - } catch (erreur) { - fautes.push(`${nom} : ${erreur.message}`); - } - couleurs.set(cle, `${rgb(couleur)} sur ${fonds.map(rgb).join(' + ')}`); - for (const a of attenuations(cible)) fautesAttenuation.push(`${nom} : ${designation(a)} ${a.propriete}: ${a.valeur}`); - }; - for (const [rang, porteur] of porteursDeTexte(racine).entries()) { - const { element } = porteur; - if (!rendu(element)) continue; - const b = element.getBoundingClientRect(); - if (b.width === 0 || b.height === 0) continue; - amener(element); - const svg = element instanceof SVGElement; - if (!svg && visible(porteur) !== true) continue; - // Un texte SVG se lit par la géométrie du dessin, non par l'atteinte - // d'un point : son centre doit tomber dans le extérieur, qui rogne - // ce qui le déborde, et dans la fenêtre. - if (svg && !centreDansLeDessin(element)) continue; - const texte = porteur.pseudo === null ? [...element.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE).map((n) => n.textContent).join('') : ''; - const nom = `${situer(element)}${porteur.pseudo ?? ''} « ${extrait(texte)} »`; - mesurer(`${rang}:${designation(porteur)}`, nom, () => couleurTexte(porteur), () => fondPeintSous(porteur), porteur); - } - for (const [rang, champ] of [...racine.querySelectorAll(CHAMPS)].entries()) { - if (!rendu(champ)) continue; - amener(champ); - if (visible(champ) !== true) continue; - const nom = designation(champ); - mesurer(`champ ${rang}:${nom}`, `${nom} (valeur)`, () => composantes(getComputedStyle(champ).color), () => fondPeintSous(champ), champ); - if ((champ.getAttribute('placeholder') ?? '') !== '') { - mesurer(`indicatif ${rang}:${nom}`, `${nom}::placeholder`, () => composantes(getComputedStyle(champ, '::placeholder').color), () => fondPeintSous(champ), champ); - } - } - return { fautes, attenuations: fautesAttenuation, couleurs, mesures }; -} - -// --- Les marques ------------------------------------------------------------------- - -/** La couleur résolue d'un jeton dans le thème courant. */ -export function couleurDeJeton(jeton) { - const sonde = document.createElement('span'); - sonde.style.color = `var(${jeton})`; - document.body.append(sonde); - try { - return composantes(getComputedStyle(sonde).color); - } finally { - sonde.remove(); - } -} - -// Des points, dans le repère propre d'une forme, où elle peint quoi -// ('fill' ou 'stroke') et que la fenêtre montre, POINTS_MAX au plus : le -// centre de sa boîte, les milieux de ses bords, puis une grille de neuf sur -// neuf, dans cet ordre ; aucun pour une forme rognée en entier. -const POINTS_MAX = 8; -function pointsPeints(forme, quoi) { - const b = forme.getBBox(); - const candidats = [ - [b.x + b.width / 2, b.y + b.height / 2], - [b.x + b.width / 2, b.y], - [b.x + b.width, b.y + b.height / 2], - [b.x + b.width / 2, b.y + b.height], - [b.x, b.y + b.height / 2], - ]; - for (let i = 0; i <= 8; i += 1) for (let j = 0; j <= 8; j += 1) candidats.push([b.x + (b.width * i) / 8, b.y + (b.height * j) / 8]); - const versEcran = forme.getScreenCTM(); - const points = []; - for (const [x, y] of candidats) { - const point = new DOMPoint(x, y); - if (!(quoi === 'stroke' ? forme.isPointInStroke(point) : forme.isPointInFill(point))) continue; - const ecran = point.matrixTransform(versEcran); - if (montreLeDessin(forme, ecran.x, ecran.y)) points.push(point); - if (points.length === POINTS_MAX) break; - } - return points; -} - -// Ce qui est peint sous la plupart des points où la forme peint : les -// couleurs dessous en chaque point, puis la liste la plus fréquente, la -// première à égalité. Un trait qui croise un autre trait en un point se -// juge sur ce qui l'entoure, non sur ce croisement. -function dessousLePlusFrequent(forme, quoi) { - const comptes = new Map(); - const points = pointsPeints(forme, quoi); - if (points.length === 0) return null; - for (const point of points) { - const couleurs = dessousAuPoint(forme, point); - const cle = couleurs.map(rgb).join(' + '); - const vu = comptes.get(cle) ?? { couleurs, n: 0 }; - vu.n += 1; - comptes.set(cle, vu); - } - return [...comptes.values()].reduce((a, b) => (b.n > a.n ? b : a)).couleurs; -} - -// Les couleurs peintes sous une forme SVG au point donné de son repère : une -// sonde sans peinture, posée juste avant elle chez son parent, se mesure par -// fondPeintSous, qui lit les formes peintes avant elle à ce point, puis ce -// qui est sous le dessin. -function dessousAuPoint(forme, point) { - const ecran = point.matrixTransform(forme.getScreenCTM()); - const parent = forme.parentNode; - const matrice = parent.getScreenCTM(); - const local = ecran.matrixTransform(matrice.inverse()); - const cote = 0.5 / Math.hypot(matrice.a, matrice.b); - const sonde = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - sonde.setAttribute('x', String(local.x - cote / 2)); - sonde.setAttribute('y', String(local.y - cote / 2)); - sonde.setAttribute('width', String(cote)); - sonde.setAttribute('height', String(cote)); - sonde.style.fill = 'none'; - sonde.style.stroke = 'none'; - parent.insertBefore(sonde, forme); - try { - return fondPeintSous(sonde); - } finally { - sonde.remove(); - } -} - -// Les bords d'un élément HTML qui se peignent, et leurs couleurs. -function bordsPeints(style) { - return ['top', 'right', 'bottom', 'left'] - .filter((cote) => style.getPropertyValue(`border-${cote}-style`) !== 'none' && parseFloat(style.getPropertyValue(`border-${cote}-width`)) > 0) - .map((cote) => composantes(style.getPropertyValue(`border-${cote}-color`))); -} - -/** - * Ce qu'une marque peint de son jeton, et ce qui l'entoure : { peinture, - * voisins }. Une forme SVG : son remplissage — une trame se mesure contre - * les autres couleurs de son motif, un aplat contre ce qui est peint dessous - * en un point qu'il couvre — ou son trait, contre ce qui est dessous en un - * point du trait et contre son propre remplissage. Un élément HTML : sa - * bordure ou son fond, contre ce qui est peint sous son parent, et son fond - * propre pour une bordure. peinture null : le jeton n'est peint nulle part. - */ -function voisinage(element, jeton) { - if (element instanceof SVGGeometryElement) { - const motif = /^url\(/.test(getComputedStyle(element).fill); - const { fill, stroke } = couleursPeintes(element); - if (fill.some((c) => egales(c, jeton))) { - if (motif) return { peinture: 'trame', voisins: fill.filter((c) => !egales(c, jeton)) }; - const dessous = dessousLePlusFrequent(element, 'fill'); - return dessous === null ? { peinture: 'cachee', voisins: [] } : { peinture: 'remplissage', voisins: dessous }; - } - if (stroke.some((c) => egales(c, jeton))) { - const dessous = dessousLePlusFrequent(element, 'stroke'); - return dessous === null ? { peinture: 'cachee', voisins: [] } : { peinture: 'trait', voisins: [...dessous, ...fill] }; - } - return { peinture: null, voisins: [] }; - } - const style = getComputedStyle(element); - const fond = composantes(style.backgroundColor); - if (bordsPeints(style).some((c) => egales(c, jeton))) { - return { peinture: 'bordure', voisins: [...(fond[3] === 1 ? [fond] : []), ...fondPeintSous(element.parentElement)] }; - } - if (egales(fond, jeton)) return { peinture: 'fond', voisins: fondPeintSous(element.parentElement) }; - return { peinture: null, voisins: [] }; -} - -/** - * Les marques rendues sous racine (§ 14.5, § 19.5) : chacune nommée au - * registre, peinte de son jeton, à 3:1 de ce qui l'entoure, sans - * atténuation. → { vues: string[], fautes, attenuations, mesures } - */ -export function releverMarques(racine) { - const vues = new Set(); - const fautes = []; - const fautesAttenuation = []; - let mesures = 0; - for (const element of racine.querySelectorAll('[data-marque]')) { - if (!rendu(element)) continue; - const nom = element.dataset.marque; - const entree = MARQUES.find((m) => m.nom === nom); - if (entree === undefined) { - fautes.push(`${situer(element)} : marque hors du registre MARQUES`); - continue; - } - for (const a of attenuations(element)) fautesAttenuation.push(`${situer(element)} : ${designation(a)} ${a.propriete}: ${a.valeur}`); - // Sous une fenêtre modale, la page est inerte et le test d'atteinte - // rencontre la fenêtre d'abord : une marque HTML de la page ne s'y - // mesure pas, comme ses textes, et se mesure sur les écrans sans - // fenêtre. Le dessin SVG, lu par sa géométrie, se mesure partout. Une - // marque ne compte comme vue que là où elle se mesure. - const modale = document.querySelector('dialog:modal'); - if (modale !== null && !(element instanceof SVGElement) && !modale.contains(element)) continue; - vues.add(nom); - amener(element); - const jeton = couleurDeJeton(entree.jeton); - try { - const { peinture, voisins } = voisinage(element, jeton); - if (peinture === null) { - fautes.push(`${situer(element)} : ne peint pas son jeton ${entree.jeton} (${rgb(jeton)})`); - continue; - } - // Rognée par un ancêtre qui défile, la marque ne se voit pas ici. - if (peinture === 'cachee') continue; - if (voisins.length === 0) { - fautes.push(`${situer(element)} : ${peinture} sans rien d'opaque autour`); - continue; - } - mesures += 1; - const { fond, ratio } = pire(jeton, voisins); - if (ratio < SEUIL_MARQUE) fautes.push(`${situer(element)} (${peinture}) : ${ratio.toFixed(2)}:1, ${rgb(jeton)} contre ${rgb(fond)}`); - } catch (erreur) { - fautes.push(`${situer(element)} : ${erreur.message}`); - } - } - return { vues: [...vues], fautes, attenuations: fautesAttenuation, mesures }; -} - // --- Les listes de noms et les tables ----------------------------------------------- // Côté de la grille de points qui couvre l'intersection de deux boîtes. @@ -1135,13 +834,11 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () => return finie; }; expect(await attendTransition()).toBe(true); - const feuille = document.createElement('style'); - feuille.textContent = '*,*::before,*::after{transition:none!important;animation:none!important}'; - document.head.append(feuille); + const retirer = poserFeuille(); try { expect(await attendTransition()).toBe(false); } finally { - feuille.remove(); + retirer(); } }); }); @@ -1360,9 +1057,7 @@ describe('balisage littéral (§ 13.1)', () => { describe('transitions annulées (§ 19.2)', () => { test('sous une feuille qui annule transitions et animations, chaque écran s’atteint', async () => { await fixerCadre(1366, 700); - const feuille = document.createElement('style'); - feuille.textContent = '*,*::before,*::after{transition:none!important;animation:none!important}'; - document.head.append(feuille); + const retirer = poserFeuille(); const fautes = []; let atteintes = 0; try { @@ -1378,10 +1073,326 @@ describe('transitions annulées (§ 19.2)', () => { } } } finally { - feuille.remove(); + retirer(); } expect(atteintes, 'écrans atteints').toBeGreaterThan(0); expect(atteintes + fautes.length).toBe(ECRANS.length); aucuneFaute(fautes, 'transitions annulées'); }, 900_000); }); + +// --- Les captures d'écran : le mécanisme ------------------------------------------------------ + +describe('les captures d’écran : chaque contrôle échoue en nommant la capture (§ 19.4, § 19.5, § 19.7, § 19.11)', () => { + const ACCUEIL = '[data-ecran="accueil"]'; + const ADRESSE_INVENTEE = 'http://192.0.2.1/'; + let retirer = null; + + beforeAll(async () => { + await fixerCadre(CADRE.largeur, CADRE.hauteur); + retirer = poserFeuille(); + await commands.requetesHorsBoucle(); + }); + + afterAll(() => { + retirer?.(); + }); + + // Un montage du premier écran, sous la provenance des captures, sur un + // dossier d'épreuve neuf ; tout deux défaits à la fin. + async function avecMontage(faire) { + const racine = await commands.dossierEpreuve(); + const m = await monterApp({ provenance: 'documentation', executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) }); + try { + await attendre('le premier écran', () => m.racine.querySelector(`${ACCUEIL} [data-dossier-travail]`)); + const racineNeutre = m.seance.etat().racine.chemin; + return await faire(m, { racine, racineNeutre, ecrire: { mode: 'ecrire', racine }, verifier: { mode: 'verifier', racine } }); + } finally { + m.demonter(); + await commands.effacerDossierEpreuve(racine); + } + } + + // Pose un élément dans le premier écran, le temps d'une capture. + function poser(m, html) { + const gabarit = document.createElement('template'); + gabarit.innerHTML = html; + const element = gabarit.content.firstElementChild; + m.racine.querySelector(ACCUEIL).append(element); + return element; + } + + // Une requête vers une adresse inventée hors de la boucle locale : l'erreur + // qu'elle rend. + async function requeteInventee() { + try { + await fetch(ADRESSE_INVENTEE, { mode: 'no-cors', signal: AbortSignal.timeout(10_000) }); + return null; + } catch (erreur) { + return erreur; + } + } + + // Les échecs hors boucle que node relève, attendus image après image : ils + // lui arrivent par le protocole de débogage, au plus quelques images + // après le rejet que la page a reçu, et après la requête elle-même. + async function echecsReleves() { + const echecs = []; + for (let image = 0; image < 60 && echecs.length === 0; image += 1) { + echecs.push(...(await commands.echecsHorsBoucle())); + await new Promise((resoudre) => requestAnimationFrame(resoudre)); + } + return echecs; + } + + // Le mandataire mort de test/chromium.js refuse la connexion : la requête + // n'est jamais partie de la machine, et Chromium le dit par sa cause. + test('réseau : une requête vers une adresse littérale inventée échoue en connexion au mandataire mort, la page de la boucle locale se charge, le relevé la nomme', async () => { + expect((await fetch('/collecteur.js')).ok).toBe(true); + expect(await commands.requetesHorsBoucle()).toEqual([]); + await commands.echecsHorsBoucle(); + const erreur = await requeteInventee(); + expect(erreur?.name, String(erreur)).toBe('TypeError'); + expect(await echecsReleves()).toEqual([{ adresse: ADRESSE_INVENTEE, cause: 'net::ERR_PROXY_CONNECTION_FAILED' }]); + expect(await commands.requetesHorsBoucle()).toEqual([ADRESSE_INVENTEE]); + }); + + test('un sélecteur manquant, une erreur au collecteur, une requête hors boucle : chacun échoue en nommant la capture', async () => { + await avecMontage(async (m, { ecrire }) => { + await expect(photographier(m, '01-premier-ecran', { ...ecrire, condition: '[data-absent]' })).rejects.toThrow( + 'capture 01-premier-ecran : sélecteur absent « [data-absent] »', + ); + await requeteInventee(); + expect(await echecsReleves()).toHaveLength(1); + await expect(photographier(m, '01-premier-ecran', ecrire)).rejects.toThrow( + `capture 01-premier-ecran : requête hors de la boucle locale : ${ADRESSE_INVENTEE}`, + ); + console.error('erreur provoquée par l’épreuve'); + await expect(photographier(m, '01-premier-ecran', ecrire)).rejects.toThrow( + 'capture 01-premier-ecran : collecteur : console.error « erreur provoquée par l’épreuve »', + ); + }); + }); + + test('un chemin hors de la racine neutre, un élément de texte vide, une clé brute, une marque de la langue témoin : chacun échoue', async () => { + await avecMontage(async (m, { ecrire }) => { + const cas = [ + ['

/home/exemple/notes.txt

', 'chemin hors de la racine neutre : « /home/exemple/notes.txt »'], + ['

Rangé sous C:\\Users\\Autre\\soiree.gtt.json

', 'chemin hors de la racine neutre : « C:\\Users\\Autre\\soiree.gtt.json »'], + ['', 'élément de texte vide : button dans '], + ['

plan.echelle.mention

', 'clé brute affichée : « plan.echelle.mention »'], + [`

${OUVERTURE}Libellé${FERMETURE}

`, `marque de la langue témoin : « ${OUVERTURE}Libellé${FERMETURE} »`], + ]; + for (const [html, nature] of cas) { + const element = poser(m, html); + try { + await expect(photographier(m, '01-premier-ecran', ecrire), html).rejects.toThrow(`capture 01-premier-ecran : ${nature}`); + } finally { + element.remove(); + } + } + }); + }); + + test('publication : zéro capture échoue ; une étape en échec ne publie rien — attente jetée, manifeste et images inchangés', async () => { + await avecMontage(async (m, { racine, racineNeutre, ecrire }) => { + const declarees = ['01-premier-ecran', '01b-passer-en-ecriture']; + await expect(commands.publierCaptures({ declarees: [], racine, racineNeutre })).rejects.toThrow('aucune capture déclarée'); + await expect(commands.publierCaptures({ declarees, racine, racineNeutre })).rejects.toThrow('aucune capture déposée'); + for (const fichier of declarees) await photographier(m, fichier, ecrire); + const bilan = await commands.publierCaptures({ declarees, racine, racineNeutre }); + expect(bilan).toEqual({ ecrites: declarees, gardees: [], retirees: [], manifeste: true }); + const publie = await commands.empreintesDossier({ racine }); + expect(publie.map(([chemin]) => chemin)).toEqual([ + 'doc/captures/01-premier-ecran.png', + 'doc/captures/01b-passer-en-ecriture.png', + 'doc/captures/manifeste.json', + ]); + + poser(m, '

Iris Ombrelle

'); + await photographier(m, '01-premier-ecran', ecrire); + await expect(photographier(m, '01b-passer-en-ecriture', { ...ecrire, condition: '[data-absent]' })).rejects.toThrow(); + expect((await commands.empreintesDossier({ racine })).map(([chemin]) => chemin)).toContain('doc/captures/.attente/01-premier-ecran.json'); + await expect(commands.publierCaptures({ declarees, racine, racineNeutre })).rejects.toThrow( + 'étapes sans capture déposée, rien n’est publié : 01b-passer-en-ecriture', + ); + expect(await commands.empreintesDossier({ racine })).toEqual(publie); + }); + }); + + test('publication : une empreinte d’étape inchangée laisse les octets de l’image ; changée, l’image se réécrit', async () => { + await avecMontage(async (m, { racine, racineNeutre, ecrire }) => { + const declarees = ['01-premier-ecran']; + const image = async () => new Map(await commands.empreintesDossier({ racine })).get('doc/captures/01-premier-ecran.png'); + await photographier(m, '01-premier-ecran', ecrire); + await commands.publierCaptures({ declarees, racine, racineNeutre }); + const premiere = await image(); + expect(premiere).toMatch(/^[0-9a-f]{64}$/); + + // Une pastille sans texte ni marque, dans un coin vide : les pixels + // changent, l'entrée non. + poser(m, '
'); + await photographier(m, '01-premier-ecran', ecrire); + const deposee = new Map(await commands.empreintesDossier({ racine })).get('doc/captures/.attente/01-premier-ecran.png'); + expect(deposee).not.toBe(premiere); + expect(await commands.publierCaptures({ declarees, racine, racineNeutre })).toEqual({ + ecrites: [], + gardees: declarees, + retirees: [], + manifeste: false, + }); + expect(await image()).toBe(premiere); + + // Un texte de plus : l'entrée change, l'image se réécrit. + poser(m, '

Théo Pervenche

'); + await photographier(m, '01-premier-ecran', ecrire); + expect(await commands.publierCaptures({ declarees, racine, racineNeutre })).toEqual({ + ecrites: declarees, + gardees: [], + retirees: [], + manifeste: true, + }); + expect(await image()).not.toBe(premiere); + }); + }); + + test('vérification : la même étape passe ; un libellé modifié est un écart qui nomme l’étape et le texte', async () => { + await avecMontage(async (m, { racine, racineNeutre, ecrire, verifier }) => { + await photographier(m, '01-premier-ecran', ecrire); + await commands.publierCaptures({ declarees: ['01-premier-ecran'], racine, racineNeutre }); + await photographier(m, '01-premier-ecran', verifier); + const titre = m.racine.querySelector(`${ACCUEIL} h2`); + const avant = titre.textContent; + titre.textContent = `${avant} modifié`; + await expect(photographier(m, '01-premier-ecran', verifier)).rejects.toThrow( + `01-premier-ecran (étape 1) : écart de texte, rang ${texteReleve(m.racine).indexOf(`${avant} modifié`)} : « ${avant} modifié » au lieu de « ${avant} »`, + ); + }); + }); + + test('cadre, facteur d’échelle, thème, chaîne témoin, feuille, contraste : chacun échoue en nommant la capture', async () => { + await avecMontage(async (m, { ecrire }) => { + const refus = (nature) => expect(photographier(m, '01-premier-ecran', ecrire)).rejects.toThrow(nature); + await fixerCadre(CADRE.largeur, CADRE.hauteur - 1); + try { + await refus(`capture 01-premier-ecran : cadre ${CADRE.largeur} × ${CADRE.hauteur - 1}, attendu ${CADRE.largeur} × ${CADRE.hauteur}`); + } finally { + await fixerCadre(CADRE.largeur, CADRE.hauteur); + } + // Le facteur que la page lit, remplacé le temps d'une capture. + const facteur = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio'); + Object.defineProperty(window, 'devicePixelRatio', { value: 2, configurable: true }); + try { + await refus('capture 01-premier-ecran : facteur d’échelle 2, attendu 1'); + } finally { + if (facteur === undefined) delete window.devicePixelRatio; + else Object.defineProperty(window, 'devicePixelRatio', facteur); + } + expect(devicePixelRatio).toBe(1); + const documentRacine = document.documentElement; + const theme = documentRacine.getAttribute('data-theme'); + documentRacine.setAttribute('data-theme', 'sombre'); + try { + await refus('capture 01-premier-ecran : thème « sombre », l’image se prend en thème clair'); + } finally { + documentRacine.setAttribute('data-theme', theme); + } + m.racine.style.setProperty('--police-interface', 'serif'); + try { + await refus(/^capture 01-premier-ecran : chaîne témoin à [\d.]+ px/); + } finally { + m.racine.style.removeProperty('--police-interface'); + } + retirer(); + try { + await refus('capture 01-premier-ecran : la feuille qui annule transitions et animations n’est pas posée'); + } finally { + retirer = poserFeuille(); + } + const pale = poser(m, '

Gris pâle

'); + try { + await refus(/^capture 01-premier-ecran : thème clair : \d+ faute\(s\) de contraste[^]*« Gris pâle »/); + } finally { + pale.remove(); + } + await photographier(m, '01-premier-ecran', ecrire); + }); + }); + + test('relevé des styles : un porteur à demi hors de la fenêtre ne fait rien défiler et n’entre pas dans les styles', async () => { + await avecMontage(async (m) => { + const releve = async () => { + const styles = {}; + for (const nom of THEMES) styles[nom] = (await sousTheme(nom, () => stylesDuTheme(m.racine))).styles; + return styles; + }; + const defilements = () => [document.scrollingElement, ...m.racine.querySelectorAll('*')].map((e) => `${e.scrollTop},${e.scrollLeft}`).join(';'); + const sans = await releve(); + // Le porteur se pose six pixels au-dessus du bord bas de la fenêtre, + // son centre au-dessous ; une cale le descend, une queue fait défiler. + const bloc = poser(m, '

Texte à demi caché

'); + const cale = bloc.querySelector('[data-cale]'); + const porteur = bloc.querySelector('p'); + cale.style.height = `${innerHeight - 6 - porteur.getBoundingClientRect().top}px`; + try { + expect(Math.round(porteur.getBoundingClientRect().top)).toBe(innerHeight - 6); + const avant = defilements(); + const avec = await releve(); + expect(defilements()).toBe(avant); + expect(avec).toEqual(sans); + } finally { + bloc.remove(); + } + }); + }); + + test('clôture : une épreuve du parcours en échec ne publie rien, jette l’attente et se nomme ; sans échec, la clôture publie', async () => { + await avecMontage(async (m, { racine, racineNeutre, ecrire }) => { + const declarees = ['01-premier-ecran']; + const clore = (echecs) => cloreCaptures({ echecs, declarees, mode: 'ecrire', racine, racineNeutre }); + await photographier(m, '01-premier-ecran', ecrire); + await expect(clore(['20 « Défaire » en lecture'])).rejects.toThrow('captures non publiées : 1 épreuve(s) du parcours en échec : « 20 « Défaire » en lecture »'); + expect(await commands.empreintesDossier({ racine })).toEqual([]); + await photographier(m, '01-premier-ecran', ecrire); + expect(await clore([])).toEqual({ declarees, bilan: { ecrites: declarees, gardees: [], retirees: [], manifeste: true } }); + }); + }); + + test('publication : un dépôt qui nomme un chemin de la machine est refusé par la garde de la contrainte 27, le chemin nommé', async () => { + await avecMontage(async (m, { racine, racineNeutre, ecrire }) => { + const entree = await photographier(m, '01-premier-ecran', ecrire); + const forgee = { ...entree, texte: [...entree.texte, '/home/exemple/x'] }; + await commands.deposerCapture({ fichier: '01-premier-ecran', entree: forgee, chemin: `${racine}/doc/captures/.attente/01-premier-ecran.png`, racine }); + await expect(commands.publierCaptures({ declarees: ['01-premier-ecran'], racine, racineNeutre })).rejects.toThrow( + 'doc/captures/01-premier-ecran.png : chemin hors de la racine neutre : « /home/exemple/x »', + ); + expect(await commands.empreintesDossier({ racine })).toEqual([]); + }); + }); + + test('publication : une image qui n’est plus déclarée part ; une image remplacée, à empreinte d’étape égale, se réécrit', async () => { + await avecMontage(async (m, { racine, racineNeutre, ecrire }) => { + const declarees = ['01-premier-ecran']; + const empreintes = async () => new Map(await commands.empreintesDossier({ racine })); + await photographier(m, '01-premier-ecran', ecrire); + await commands.copierDansEpreuve({ racine, depuis: 'doc/captures/.attente/01-premier-ecran.png', vers: 'doc/captures/09-refus-sur-place.png' }); + expect((await empreintes()).has('doc/captures/09-refus-sur-place.png')).toBe(true); + const bilan = await commands.publierCaptures({ declarees, racine, racineNeutre }); + expect(bilan.retirees).toEqual(['09-refus-sur-place']); + expect([...(await empreintes()).keys()]).toEqual(['doc/captures/01-premier-ecran.png', 'doc/captures/manifeste.json']); + const publiee = (await empreintes()).get('doc/captures/01-premier-ecran.png'); + + // Une autre image prend la place de la publiée : une pastille change + // les pixels, l'entrée non. + const pastille = poser(m, '
'); + await photographier(m, '01-premier-ecran', ecrire); + pastille.remove(); + await commands.copierDansEpreuve({ racine, depuis: 'doc/captures/.attente/01-premier-ecran.png', vers: 'doc/captures/01-premier-ecran.png' }); + const remplacee = (await empreintes()).get('doc/captures/01-premier-ecran.png'); + expect(remplacee).not.toBe(publiee); + await photographier(m, '01-premier-ecran', ecrire); + expect(await commands.publierCaptures({ declarees, racine, racineNeutre })).toMatchObject({ ecrites: declarees, gardees: [], retirees: [] }); + expect((await empreintes()).get('doc/captures/01-premier-ecran.png')).not.toBe(remplacee); + }); + }); +}); diff --git a/test/chromium.js b/test/chromium.js index 8986db6..2838663 100644 --- a/test/chromium.js +++ b/test/chromium.js @@ -21,6 +21,20 @@ // alors cette cause. C.UTF-8 vient avec la glibc 2.35 et suivantes, et avec // les distributions qui le livrent avant ; l'environnement reçu reste tel // quel. +// +// Le projet navigateur reçoit optionsNavigateur : les mêmes options, plus +// trois arguments de lancement et un contexte au facteur d'échelle 1 +// (§ 19.2, § 19.4). Le résolveur de Chromium ne résout que localhost ; un +// mandataire mort, 127.0.0.1:9, reçoit toute requête vers une adresse +// littérale, que le résolveur ne voit pas. Chromium contourne d'office le +// mandataire pour la boucle locale — localhost, 127.0.0.0/8, [::1] — : la +// page des épreuves se charge, et une requête qui sortirait de la machine +// échoue en connexion, observable, au lieu de partir. La règle de +// contournement <-loopback> retirerait ce contournement ; elle n'est pas +// posée. Le facteur d'échelle se fixe au lancement, que ni Playwright ni +// une commande du standard ne fixent pour la fenêtre de la machine. +// suivreRequetes relève, page par page, les requêtes hors de la boucle +// locale que la page émet, refusées ou non, et la cause de chaque échec. // Un nom de paramètres régionaux dont le jeu de caractères est UTF-8 : // « fr_CA.UTF-8 », « C.utf8 », « de_DE.UTF-8@euro ». @@ -44,3 +58,92 @@ export function optionsChromium(env) { } return Object.keys(lancement).length > 0 ? { launchOptions: lancement } : {}; } + +/** Les arguments de lancement du projet navigateur, dans cet ordre. */ +export const ARGUMENTS_NAVIGATEUR = Object.freeze([ + '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost', + '--proxy-server=127.0.0.1:9', + '--force-device-scale-factor=1', +]); + +/** Le mode d'affichage du navigateur des captures, nommé au manifeste + * (§ 19.4) : une valeur de la configuration, jamais lue de la machine. */ +export const MODE_AFFICHAGE = 'chromium-headless-shell'; + +/** + * Les options du fournisseur Playwright du projet navigateur : celles de + * optionsChromium(env), ARGUMENTS_NAVIGATEUR ajoutés à la fin des arguments + * de lancement, et un contexte de navigation au facteur d'échelle 1. env + * n'est pas modifié. + * + * @param {Record} env + */ +export function optionsNavigateur(env) { + const { launchOptions = {}, ...autres } = optionsChromium(env); + return { + ...autres, + launchOptions: { ...launchOptions, args: [...(launchOptions.args ?? []), ...ARGUMENTS_NAVIGATEUR] }, + contextOptions: { deviceScaleFactor: 1 }, + }; +} + +// Les schémas d'une adresse qui ne passe pas par le réseau. +const SANS_RESEAU = new Set(['data:', 'blob:', 'about:']); + +// Un nom d'hôte de la boucle locale, tel que l'URL le rend : localhost, +// une adresse IPv4 de 127.0.0.0/8, ou [::1]. +const BOUCLE = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/; + +/** + * Vrai quand une requête vers cette adresse sortirait de la machine : toute + * adresse dont l'hôte n'est pas de la boucle locale, hors des schémas sans + * réseau (data:, blob:, about:). Une adresse illisible compte comme sortante. + * + * @param {string} adresse + * @returns {boolean} + */ +export function horsBoucle(adresse) { + let url; + try { + url = new URL(adresse); + } catch { + return true; + } + if (SANS_RESEAU.has(url.protocol)) return false; + return !BOUCLE.test(url.hostname); +} + +// Les requêtes hors boucle relevées par page, et leurs échecs, en attente +// d'être lus. +const RELEVES = new WeakMap(); + +/** + * Suit les requêtes de la page Playwright : la première fois, deux + * écouteurs se posent sur la page, « request » et « requestfailed », qui + * relèvent chaque adresse hors de la boucle locale, cadres compris, et + * chaque échec d'une telle requête avec sa cause — l'errorText de + * Chromium, net::ERR_PROXY_CONNECTION_FAILED quand le mandataire mort l'a + * reçue. Rend { relever(), releverEchecs() } : les adresses, puis les + * échecs { adresse, cause }, relevés depuis la lecture précédente, dans + * l'ordre, chacun vidé par sa lecture. Deux suivis de la même page + * partagent le même relevé. + * + * @param {{on: (evenement: string, ecouteur: Function) => unknown}} page + * @returns {{relever: () => string[], releverEchecs: () => Array<{adresse: string, cause: string|null}>}} + */ +export function suivreRequetes(page) { + if (!RELEVES.has(page)) { + const releve = { requetes: [], echecs: [] }; + RELEVES.set(page, releve); + page.on('request', (requete) => { + const adresse = requete.url(); + if (horsBoucle(adresse)) releve.requetes.push(adresse); + }); + page.on('requestfailed', (requete) => { + const adresse = requete.url(); + if (horsBoucle(adresse)) releve.echecs.push({ adresse, cause: requete.failure()?.errorText ?? null }); + }); + } + const { requetes, echecs } = RELEVES.get(page); + return { relever: () => requetes.splice(0, requetes.length), releverEchecs: () => echecs.splice(0, echecs.length) }; +} diff --git a/test/chromium.test.js b/test/chromium.test.js index 9296b3b..a9d361d 100644 --- a/test/chromium.test.js +++ b/test/chromium.test.js @@ -3,12 +3,21 @@ // Le Chromium des épreuves du navigateur : celui de Playwright par défaut, // celui que GTT_CHROMIUM désigne sinon ; sous des paramètres régionaux qui ne -// sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. vitest.config.js -// applique la règle. +// sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. Le projet +// navigateur le lance sans réseau hors de la boucle locale, au facteur +// d'échelle 1 (§ 19.2, § 19.4), et lui donne les commandes et le mode des +// captures d'écran (§ 19.5). vitest.config.js applique la règle ; le Makefile +// lance la publication. import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import configuration from '../vitest.config.js'; -import { optionsChromium } from './chromium.js'; +import { ARGUMENTS_NAVIGATEUR, horsBoucle, optionsChromium, optionsNavigateur, suivreRequetes } from './chromium.js'; import { describe, test } from './lanceur.js'; +import * as commandesDocumentation from './navigateur/commandes_documentation.js'; + +const RACINE = fileURLToPath(new URL('..', import.meta.url)); // Un environnement d'épreuve, sous des paramètres régionaux UTF-8. const UTF8 = { PATH: '/usr/bin', LANG: 'fr_CA.UTF-8' }; @@ -25,9 +34,9 @@ describe('chromium : l’exécutable des épreuves du navigateur', () => { }); }); - test('vitest.config.js passe au projet navigateur les options de cet environnement', () => { + test('vitest.config.js passe au projet navigateur les options de cet environnement, réseau et échelle compris', () => { const navigateur = configuration.test.projects.find(({ test: { name } }) => name === 'navigateur'); - assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env)); + assert.deepEqual(navigateur.test.browser.provider.options, optionsNavigateur(process.env)); }); }); @@ -71,3 +80,132 @@ describe('chromium : les paramètres régionaux du navigateur des épreuves', () }); }); }); + +describe('chromium : aucun réseau hors de la boucle locale, le facteur d’échelle 1 (§ 19.2, § 19.4)', () => { + test('les arguments du navigateur : le résolveur ne résout que la boucle locale, un mandataire mort reçoit le reste, le facteur d’échelle est fixé au lancement', () => { + assert.deepEqual(ARGUMENTS_NAVIGATEUR, [ + '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost', + '--proxy-server=127.0.0.1:9', + '--force-device-scale-factor=1', + ]); + // La boucle locale contourne le mandataire d'office ; <-loopback> la + // ferait passer par lui, et la page des épreuves ne se chargerait plus. + assert.equal(ARGUMENTS_NAVIGATEUR.some((argument) => argument.includes('<-loopback>')), false); + assert.equal(Object.isFrozen(ARGUMENTS_NAVIGATEUR), true); + }); + + test('optionsNavigateur : les options de optionsChromium, les arguments ajoutés, un contexte au facteur 1 ; l’environnement reçu reste tel quel', () => { + assert.deepEqual(optionsNavigateur({ LANG: 'fr_CA.UTF-8' }), { + launchOptions: { args: [...ARGUMENTS_NAVIGATEUR] }, + contextOptions: { deviceScaleFactor: 1 }, + }); + const env = { LC_ALL: 'C', GTT_CHROMIUM: '/opt/essai/chromium' }; + assert.deepEqual(optionsNavigateur(env), { + launchOptions: { + executablePath: '/opt/essai/chromium', + env: { GTT_CHROMIUM: '/opt/essai/chromium', LANG: 'C.UTF-8' }, + args: [...ARGUMENTS_NAVIGATEUR], + }, + contextOptions: { deviceScaleFactor: 1 }, + }); + assert.deepEqual(env, { LC_ALL: 'C', GTT_CHROMIUM: '/opt/essai/chromium' }); + }); + + test('horsBoucle : une adresse hors de la boucle locale est relevée ; la boucle locale et les adresses sans réseau ne le sont pas', () => { + const dehors = [ + 'http://192.0.2.1/', + 'https://exemple.invalid/police.woff2', + 'ws://198.51.100.7:8080/', + 'http://localhost.exemple.invalid/', + 'http://127.0.0.1.exemple.invalid/', + 'http://[2001:db8::1]/', + 'ftp://exemple.invalid/x', + 'gopher://exemple.invalid/', + ]; + const dedans = [ + 'http://localhost:63315/__vitest_test__/', + 'ws://localhost:63315/__vitest_browser_api__', + 'http://LOCALHOST/', + 'http://127.0.0.1:5173/collecteur.js', + 'http://127.8.9.10/', + 'http://[::1]:63315/', + 'data:text/plain,x', + 'blob:http://localhost:63315/0f3c', + 'about:blank', + ]; + for (const adresse of dehors) assert.equal(horsBoucle(adresse), true, adresse); + for (const adresse of dedans) assert.equal(horsBoucle(adresse), false, adresse); + assert.equal(horsBoucle('pas une adresse'), true); + }); + + test('suivreRequetes : chaque requête hors boucle de la page, relevée une fois et dans l’ordre ; un second suivi de la même page reprend le premier', () => { + const ecouteurs = []; + const page = { on: (evenement, fn) => ecouteurs.push([evenement, fn]) }; + const requete = (adresse) => ({ url: () => adresse }); + const suivi = suivreRequetes(page); + assert.deepEqual(ecouteurs.map(([evenement]) => evenement), ['request', 'requestfailed']); + const [[, ecouter]] = ecouteurs; + ecouter(requete('http://localhost:63315/a.js')); + ecouter(requete('http://192.0.2.1/')); + ecouter(requete('https://exemple.invalid/b')); + assert.deepEqual(suivreRequetes(page).relever(), ['http://192.0.2.1/', 'https://exemple.invalid/b']); + assert.equal(ecouteurs.length, 2); + assert.deepEqual(suivi.relever(), []); + ecouter(requete('http://192.0.2.1/c')); + assert.deepEqual(suivi.relever(), ['http://192.0.2.1/c']); + assert.deepEqual(suivreRequetes({ on: () => {} }).relever(), []); + }); + + test('suivreRequetes : chaque échec d’une requête hors boucle, avec sa cause, relevé une fois ; ceux de la boucle locale ne le sont pas', () => { + const ecouteurs = new Map(); + const page = { on: (evenement, fn) => ecouteurs.set(evenement, fn) }; + const suivi = suivreRequetes(page); + assert.deepEqual([...ecouteurs.keys()], ['request', 'requestfailed']); + const echec = (adresse, errorText) => ({ url: () => adresse, failure: () => (errorText === null ? null : { errorText }) }); + ecouteurs.get('requestfailed')(echec('http://localhost:63315/a.js', 'net::ERR_ABORTED')); + ecouteurs.get('requestfailed')(echec('http://192.0.2.1/', 'net::ERR_PROXY_CONNECTION_FAILED')); + ecouteurs.get('requestfailed')(echec('http://192.0.2.1/b', null)); + assert.deepEqual(suivi.releverEchecs(), [ + { adresse: 'http://192.0.2.1/', cause: 'net::ERR_PROXY_CONNECTION_FAILED' }, + { adresse: 'http://192.0.2.1/b', cause: null }, + ]); + assert.deepEqual(suivi.releverEchecs(), []); + assert.deepEqual(suivi.relever(), []); + }); +}); + +describe('le projet navigateur : les commandes et le mode des captures d’écran (§ 19.5)', () => { + const navigateur = configuration.test.projects.find(({ test: { name } }) => name === 'navigateur'); + + test('modeCaptures : verifier par défaut, ecrire sous GTT_CAPTURES=ecrire ; une autre valeur lève en la nommant', () => { + assert.equal(commandesDocumentation.modeCaptures({}), 'verifier'); + assert.equal(commandesDocumentation.modeCaptures({ GTT_CAPTURES: '' }), 'verifier'); + assert.equal(commandesDocumentation.modeCaptures({ GTT_CAPTURES: 'verifier' }), 'verifier'); + assert.equal(commandesDocumentation.modeCaptures({ GTT_CAPTURES: 'ecrire' }), 'ecrire'); + assert.throws(() => commandesDocumentation.modeCaptures({ GTT_CAPTURES: 'ecrir' }), /GTT_CAPTURES.*« ecrir »/); + }); + + test('vitest.config.js fournit le mode à la page par provide, et déclare chaque commande de commandes_documentation.js', () => { + assert.deepEqual(navigateur.test.provide, { captures: { mode: commandesDocumentation.modeCaptures(process.env) } }); + const declarees = navigateur.test.browser.commands; + assert.deepEqual(Object.keys(declarees).sort(), [...commandesDocumentation.COMMANDES].sort()); + for (const nom of commandesDocumentation.COMMANDES) { + assert.equal(typeof commandesDocumentation[nom], 'function', nom); + assert.equal(declarees[nom], commandesDocumentation[nom], nom); + } + assert.ok(commandesDocumentation.COMMANDES.length > 0); + }); + + test('make captures lance le parcours du projet navigateur sous GTT_CAPTURES=ecrire, par avec_node ; .gitignore tait le dossier d’attente', () => { + const makefile = readFileSync(join(RACINE, 'Makefile'), 'utf8'); + assert.match(makefile, /^\.PHONY: captures$/m); + assert.match(makefile, /^captures: export GTT_CAPTURES := ecrire$/m); + assert.match( + makefile, + /^captures: ## Photographie le parcours et publie doc\/captures \(§ 19\.5\)\n\t\$\(AVEC_NODE\) npx vitest run --project navigateur src\/interface\/parcours\.navigateur\.test\.js$/m, + ); + const ignores = readFileSync(join(RACINE, '.gitignore'), 'utf8').split('\n'); + assert.ok(ignores.includes('doc/captures/.attente/'), ignores.join(' | ')); + assert.equal(ignores.some((ligne) => /^doc\/captures\/?$|^doc\/?$|\*\.png/.test(ligne)), false); + }); +}); diff --git a/test/navigateur/captures.js b/test/navigateur/captures.js new file mode 100644 index 0000000..3d46e59 --- /dev/null +++ b/test/navigateur/captures.js @@ -0,0 +1,772 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les captures d'écran du parcours, côté page (§ 19.2 à § 19.5, § 19.7, +// § 19.11, § 14.5). photographier(m, fichier) contrôle l'écran du montage +// m, relève ce que le manifeste en garde, puis, selon le mode, compare +// l'entrée à celle du manifeste ou dépose image et entrée pour la +// publication. Les fichiers, le manifeste et la capture d'élément sont de +// node : les commandes de test/navigateur/commandes_documentation.js. +// +// Le mode vient de provide (vitest.config.js, GTT_CAPTURES) : 'verifier' +// par défaut, 'ecrire' sous make captures ; une option le remplace pour une +// épreuve du mécanisme, sur un dossier d'épreuve. +// +// Les relevés de contraste des textes et des marques vivent ici, une seule +// fois : l'épreuve transversale les lit sur chaque écran, photographier les +// range en styles du manifeste, par jeton et par rôle, dans les deux thèmes. +// Un relevé complet amène dans la fenêtre l'élément qu'il mesure ; le +// relevé de la fenêtre ne fait rien défiler et ne lit que ce que l'image +// montre. photographier rend après chaque thème, à chaque élément qui +// défile, sa position d'avant. +import { inject } from 'vitest'; +import { commands } from 'vitest/browser'; +import { TABLES_APPLICATION } from '../../src/application/libelles.js'; +import { MARQUE_TEMOIN, creerTraduction } from '../../src/application/traduction.js'; +import { constante } from '../../src/geometrie/constantes.js'; +import JETONS_CSS from '../../src/interface/jetons.css?raw'; +import { TABLES_INTERFACE } from '../../src/interface/libelles.js'; +import { MARQUES, TEXTES } from '../../src/interface/marques.js'; +import { serialiserCharge } from '../../src/stockage/canonique.js'; +import { analyser } from '../../src/stockage/document.js'; +import { SUFFIXES } from '../../src/stockage/noms.js'; +import { VERSION } from '../../src/version.genere.js'; +import { MODE_AFFICHAGE } from '../chromium.js'; +import { CHAINE_TEMOIN, longueurTemoin } from '../temoin.js'; +import { feuillePosee } from './feuilles.js'; +import { + attenuations, + composantes, + contraste, + couleurTexte, + couleursPeintes, + designation, + fondPeintSous, + porteursDeTexte, + sousTheme, + visible, +} from './rendu.js'; + +/** Le cadre d'affichage des captures, en px CSS (§ 19.4). */ +export const CADRE = Object.freeze({ largeur: 1366, hauteur: 700 }); + +// Seuils du § 14.5 : le texte, les marques porteuses d'information. +const SEUIL_TEXTE = 4.5; +const SEUIL_MARQUE = 3; + +// --- Les aides des relevés --------------------------------------------------------- + +// Une couleur en texte, pour un message ; l'égalité de deux couleurs ; un +// texte aplati, réduit à soixante caractères ; un élément rendu. +export const rgb = (c) => `rgb(${c.slice(0, 3).join(', ')}${c[3] === 1 ? '' : `, ${c[3]}`})`; +export const egales = (a, b) => a.join() === b.join(); +export const extrait = (texte) => { + const plat = texte.replace(/\s+/g, ' ').trim(); + return plat.length > 60 ? `${plat.slice(0, 57)}…` : plat; +}; +export const rendu = (element) => element.checkVisibility({ visibilityProperty: true, opacityProperty: true }); + +// Les conteneurs désignés qui situent un élément dans son écran. +const REPERES = '[data-liste], [data-siege], [data-table], [data-pastille], [data-panneau], [data-menu], [data-section], [data-ecran], [data-bandeau]'; + +// Le nom d'un élément, suivi de celui du plus proche conteneur désigné qui +// le porte : un tspan se situe par sa liste, une pastille par sa table. +export function situer(element) { + const repere = element.parentElement?.closest(REPERES) ?? null; + return repere === null ? designation(element) : `${designation(element)} dans ${designation(repere)}`; +} + + +// --- Les contrastes des textes et les atténuations -------------------------------- + +// Vrai quand la boîte de l'élément coupe la fenêtre, bords exclus. +function coupeLaFenetre(element) { + const b = element.getBoundingClientRect(); + return b.right > 0 && b.bottom > 0 && b.left < innerWidth && b.top < innerHeight; +} + +// Vrai quand la boîte de l'élément tient entière dans la fenêtre et dans +// chaque ancêtre qui rogne ce qui le déborde — overflow autre que visible, +// ou un —, à un demi-pixel près. +function entiereDansLaFenetre(element) { + const b = element.getBoundingClientRect(); + const dedans = (r) => b.left >= r.left - 0.5 && b.top >= r.top - 0.5 && b.right <= r.right + 0.5 && b.bottom <= r.bottom + 0.5; + if (!dedans({ left: 0, top: 0, right: innerWidth, bottom: innerHeight })) return false; + for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) { + const rogne = ancetre.localName === 'svg' || getComputedStyle(ancetre).overflow !== 'visible'; + if (rogne && !dedans(ancetre.getBoundingClientRect())) return false; + } + return true; +} + +// Amène un élément dans la fenêtre, s'il en sort. +function amener(element) { + const b = element.getBoundingClientRect(); + if (b.left < 0 || b.top < 0 || b.right > innerWidth || b.bottom > innerHeight) { + element.scrollIntoView({ block: 'center', inline: 'nearest' }); + } +} + +// Le le plus extérieur qui contient un élément SVG. +function svgExterieur(element) { + let svg = element.ownerSVGElement ?? element; + while (svg.ownerSVGElement !== null) svg = svg.ownerSVGElement; + return svg; +} + +// Feuille qui rend chaque élément atteignable le temps d'une lecture, comme +// celle de rendu.js : elementsFromPoint suit l'atteinte du pointeur. +const ATTEIGNABLE = '*, *::before, *::after { pointer-events: auto !important; }'; + +// Le point (x, y) de la fenêtre montre-t-il le dessin qui porte l'élément ? +// Dans la fenêtre, et le extérieur, ou l'un de ses descendants, parmi +// les éléments présents au point : un ancêtre qui défile rogne ce qui +// déborde de sa boîte, et le point rogné n'atteint plus le dessin. +function montreLeDessin(element, x, y) { + if (!(x >= 0 && y >= 0 && x < innerWidth && y < innerHeight)) return false; + const svg = svgExterieur(element); + const feuille = document.createElement('style'); + feuille.textContent = ATTEIGNABLE; + document.head.append(feuille); + try { + return document.elementsFromPoint(x, y).some((n) => svg.contains(n)); + } finally { + feuille.remove(); + } +} + +// Le centre de la boîte d'un élément SVG montre-t-il son dessin ? +function centreDansLeDessin(element) { + const b = element.getBoundingClientRect(); + return montreLeDessin(element, b.left + b.width / 2, b.top + b.height / 2); +} + +// Le plus faible contraste d'une couleur sur une liste de fonds. +const pire = (couleur, fonds) => + fonds.map((fond) => ({ fond, ratio: contraste(couleur, fond) })).reduce((a, b) => (b.ratio < a.ratio ? b : a)); + +// Les champs de saisie dont la valeur ou le texte indicatif se peint. +export const CHAMPS = 'input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]), select, textarea'; + +/** + * Contrastes des textes rendus sous racine (§ 14.5) : chaque porteur de + * texte et chaque champ de saisie, son texte indicatif compris, sur le fond + * peint dessous ; un texte SVG par son fill sur la forme dessous. Un porteur + * que l'œil ne voit pas — couvert, rogné, de boîte vide — n'est pas mesuré. + * → { fautes, attenuations, couleurs: Map(clé → couleurs), mesures, ratios }, + * ratios : { couleur, fond, ratio } de chaque texte mesuré, le fond le plus + * défavorable, dans l'ordre des mesures. fenetre vrai : seuls les éléments + * dont la boîte coupe la fenêtre se mesurent, sans rien amener — ce que + * montre une capture d'écran. + */ +export function releverTextes(racine, { fenetre = false } = {}) { + const fautes = []; + const fautesAttenuation = []; + const couleurs = new Map(); + const ratios = []; + let mesures = 0; + const mesurer = (cle, nom, couleurDe, fondDe, cible) => { + let couleur; + let fonds; + try { + couleur = couleurDe(); + fonds = fondDe(); + } catch (erreur) { + fautes.push(`${nom} : ${erreur.message}`); + return; + } + mesures += 1; + if (fonds.length === 0) { + fautes.push(`${nom} : aucun fond opaque dessous`); + return; + } + try { + const { fond, ratio } = pire(couleur, fonds); + ratios.push({ couleur, fond, ratio }); + if (ratio < SEUIL_TEXTE) fautes.push(`${nom} : ${ratio.toFixed(2)}:1, ${rgb(couleur)} sur ${rgb(fond)}`); + } catch (erreur) { + fautes.push(`${nom} : ${erreur.message}`); + } + couleurs.set(cle, `${rgb(couleur)} sur ${fonds.map(rgb).join(' + ')}`); + for (const a of attenuations(cible)) fautesAttenuation.push(`${nom} : ${designation(a)} ${a.propriete}: ${a.valeur}`); + }; + for (const [rang, porteur] of porteursDeTexte(racine).entries()) { + const { element } = porteur; + if (fenetre && !coupeLaFenetre(element)) continue; + if (!rendu(element)) continue; + const b = element.getBoundingClientRect(); + if (b.width === 0 || b.height === 0) continue; + if (!fenetre) amener(element); + const svg = element instanceof SVGElement; + if (!svg && visible(porteur) !== true) continue; + // Un texte SVG se lit par la géométrie du dessin, non par l'atteinte + // d'un point : son centre doit tomber dans le extérieur, qui rogne + // ce qui le déborde, et dans la fenêtre. + if (svg && !centreDansLeDessin(element)) continue; + const texte = porteur.pseudo === null ? [...element.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE).map((n) => n.textContent).join('') : ''; + const nom = `${situer(element)}${porteur.pseudo ?? ''} « ${extrait(texte)} »`; + mesurer(`${rang}:${designation(porteur)}`, nom, () => couleurTexte(porteur), () => fondPeintSous(porteur), porteur); + } + for (const [rang, champ] of [...racine.querySelectorAll(CHAMPS)].entries()) { + if (fenetre && !coupeLaFenetre(champ)) continue; + if (!rendu(champ)) continue; + if (!fenetre) amener(champ); + if (visible(champ) !== true) continue; + const nom = designation(champ); + mesurer(`champ ${rang}:${nom}`, `${nom} (valeur)`, () => composantes(getComputedStyle(champ).color), () => fondPeintSous(champ), champ); + if ((champ.getAttribute('placeholder') ?? '') !== '') { + mesurer(`indicatif ${rang}:${nom}`, `${nom}::placeholder`, () => composantes(getComputedStyle(champ, '::placeholder').color), () => fondPeintSous(champ), champ); + } + } + return { fautes, attenuations: fautesAttenuation, couleurs, mesures, ratios }; +} + +// --- Les marques ------------------------------------------------------------------- + +/** La couleur résolue d'un jeton dans le thème courant. */ +export function couleurDeJeton(jeton) { + const sonde = document.createElement('span'); + sonde.style.color = `var(${jeton})`; + document.body.append(sonde); + try { + return composantes(getComputedStyle(sonde).color); + } finally { + sonde.remove(); + } +} + +// Des points, dans le repère propre d'une forme, où elle peint quoi +// ('fill' ou 'stroke') et que la fenêtre montre, POINTS_MAX au plus : le +// centre de sa boîte, les milieux de ses bords, puis une grille de neuf sur +// neuf, dans cet ordre ; aucun pour une forme rognée en entier. +const POINTS_MAX = 8; +function pointsPeints(forme, quoi) { + const b = forme.getBBox(); + const candidats = [ + [b.x + b.width / 2, b.y + b.height / 2], + [b.x + b.width / 2, b.y], + [b.x + b.width, b.y + b.height / 2], + [b.x + b.width / 2, b.y + b.height], + [b.x, b.y + b.height / 2], + ]; + for (let i = 0; i <= 8; i += 1) for (let j = 0; j <= 8; j += 1) candidats.push([b.x + (b.width * i) / 8, b.y + (b.height * j) / 8]); + const versEcran = forme.getScreenCTM(); + const points = []; + for (const [x, y] of candidats) { + const point = new DOMPoint(x, y); + if (!(quoi === 'stroke' ? forme.isPointInStroke(point) : forme.isPointInFill(point))) continue; + const ecran = point.matrixTransform(versEcran); + if (montreLeDessin(forme, ecran.x, ecran.y)) points.push(point); + if (points.length === POINTS_MAX) break; + } + return points; +} + +// Ce qui est peint sous la plupart des points où la forme peint : les +// couleurs dessous en chaque point, puis la liste la plus fréquente, la +// première à égalité. Un trait qui croise un autre trait en un point se +// juge sur ce qui l'entoure, non sur ce croisement. +function dessousLePlusFrequent(forme, quoi) { + const comptes = new Map(); + const points = pointsPeints(forme, quoi); + if (points.length === 0) return null; + for (const point of points) { + const couleurs = dessousAuPoint(forme, point); + const cle = couleurs.map(rgb).join(' + '); + const vu = comptes.get(cle) ?? { couleurs, n: 0 }; + vu.n += 1; + comptes.set(cle, vu); + } + return [...comptes.values()].reduce((a, b) => (b.n > a.n ? b : a)).couleurs; +} + +// Les couleurs peintes sous une forme SVG au point donné de son repère : une +// sonde sans peinture, posée juste avant elle chez son parent, se mesure par +// fondPeintSous, qui lit les formes peintes avant elle à ce point, puis ce +// qui est sous le dessin. +function dessousAuPoint(forme, point) { + const ecran = point.matrixTransform(forme.getScreenCTM()); + const parent = forme.parentNode; + const matrice = parent.getScreenCTM(); + const local = ecran.matrixTransform(matrice.inverse()); + const cote = 0.5 / Math.hypot(matrice.a, matrice.b); + const sonde = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + sonde.setAttribute('x', String(local.x - cote / 2)); + sonde.setAttribute('y', String(local.y - cote / 2)); + sonde.setAttribute('width', String(cote)); + sonde.setAttribute('height', String(cote)); + sonde.style.fill = 'none'; + sonde.style.stroke = 'none'; + parent.insertBefore(sonde, forme); + try { + return fondPeintSous(sonde); + } finally { + sonde.remove(); + } +} + +// Les bords d'un élément HTML qui se peignent, et leurs couleurs. +export function bordsPeints(style) { + return ['top', 'right', 'bottom', 'left'] + .filter((cote) => style.getPropertyValue(`border-${cote}-style`) !== 'none' && parseFloat(style.getPropertyValue(`border-${cote}-width`)) > 0) + .map((cote) => composantes(style.getPropertyValue(`border-${cote}-color`))); +} + +/** + * Ce qu'une marque peint de son jeton, et ce qui l'entoure : { peinture, + * voisins }. Une forme SVG : son remplissage — une trame se mesure contre + * les autres couleurs de son motif, un aplat contre ce qui est peint dessous + * en un point qu'il couvre — ou son trait, contre ce qui est dessous en un + * point du trait et contre son propre remplissage. Un élément HTML : sa + * bordure ou son fond, contre ce qui est peint sous son parent, et son fond + * propre pour une bordure. peinture null : le jeton n'est peint nulle part. + */ +function voisinage(element, jeton) { + if (element instanceof SVGGeometryElement) { + const motif = /^url\(/.test(getComputedStyle(element).fill); + const { fill, stroke } = couleursPeintes(element); + if (fill.some((c) => egales(c, jeton))) { + if (motif) return { peinture: 'trame', voisins: fill.filter((c) => !egales(c, jeton)) }; + const dessous = dessousLePlusFrequent(element, 'fill'); + return dessous === null ? { peinture: 'cachee', voisins: [] } : { peinture: 'remplissage', voisins: dessous }; + } + if (stroke.some((c) => egales(c, jeton))) { + const dessous = dessousLePlusFrequent(element, 'stroke'); + return dessous === null ? { peinture: 'cachee', voisins: [] } : { peinture: 'trait', voisins: [...dessous, ...fill] }; + } + return { peinture: null, voisins: [] }; + } + const style = getComputedStyle(element); + const fond = composantes(style.backgroundColor); + if (bordsPeints(style).some((c) => egales(c, jeton))) { + return { peinture: 'bordure', voisins: [...(fond[3] === 1 ? [fond] : []), ...fondPeintSous(element.parentElement)] }; + } + if (egales(fond, jeton)) return { peinture: 'fond', voisins: fondPeintSous(element.parentElement) }; + return { peinture: null, voisins: [] }; +} + +/** + * Les marques rendues sous racine (§ 14.5, § 19.5) : chacune nommée au + * registre, peinte de son jeton, à 3:1 de ce qui l'entoure, sans + * atténuation. → { vues: string[], fautes, attenuations, mesures, ratios }, + * ratios : { marque, jeton, ratio } de chaque marque mesurée, contre ce qui + * l'entoure de plus défavorable, dans l'ordre des mesures. fenetre vrai : + * seules les marques que la fenêtre montre entières se lisent, sans rien + * amener : une marque rognée par le bord de la fenêtre ou d'un ancêtre qui + * rogne ne montre qu'une partie de ce qui l'entoure — une chaise coupée par + * le bord du plan ne garde parfois, de son trait, que le point où il touche + * le trait du plateau. + */ +export function releverMarques(racine, { fenetre = false } = {}) { + const vues = new Set(); + const ratios = []; + const fautes = []; + const fautesAttenuation = []; + let mesures = 0; + for (const element of racine.querySelectorAll('[data-marque]')) { + if (fenetre && !entiereDansLaFenetre(element)) continue; + if (!rendu(element)) continue; + const nom = element.dataset.marque; + const entree = MARQUES.find((m) => m.nom === nom); + if (entree === undefined) { + fautes.push(`${situer(element)} : marque hors du registre MARQUES`); + continue; + } + for (const a of attenuations(element)) fautesAttenuation.push(`${situer(element)} : ${designation(a)} ${a.propriete}: ${a.valeur}`); + // Sous une fenêtre modale, la page est inerte et le test d'atteinte + // rencontre la fenêtre d'abord : une marque HTML de la page ne s'y + // mesure pas, comme ses textes, et se mesure sur les écrans sans + // fenêtre. Le dessin SVG, lu par sa géométrie, se mesure partout. Une + // marque ne compte comme vue que là où elle se mesure. + const modale = document.querySelector('dialog:modal'); + if (modale !== null && !(element instanceof SVGElement) && !modale.contains(element)) continue; + vues.add(nom); + if (!fenetre) amener(element); + const jeton = couleurDeJeton(entree.jeton); + try { + const { peinture, voisins } = voisinage(element, jeton); + if (peinture === null) { + fautes.push(`${situer(element)} : ne peint pas son jeton ${entree.jeton} (${rgb(jeton)})`); + continue; + } + // Rognée par un ancêtre qui défile, la marque ne se voit pas ici. + if (peinture === 'cachee') continue; + if (voisins.length === 0) { + fautes.push(`${situer(element)} : ${peinture} sans rien d'opaque autour`); + continue; + } + mesures += 1; + const { fond, ratio } = pire(jeton, voisins); + ratios.push({ marque: nom, jeton: entree.jeton, ratio }); + if (ratio < SEUIL_MARQUE) fautes.push(`${situer(element)} (${peinture}) : ${ratio.toFixed(2)}:1, ${rgb(jeton)} contre ${rgb(fond)}`); + } catch (erreur) { + fautes.push(`${situer(element)} : ${erreur.message}`); + } + } + return { vues: [...vues], fautes, attenuations: fautesAttenuation, mesures, ratios }; +} + + +// --- La capture d'écran d'une étape --------------------------------------------------- + +const THEMES = Object.freeze(['clair', 'sombre']); + +// Le corps, en px, de la chaîne témoin mesurée : grand, pour que l'écart +// relatif lise la police et non l'arrondi d'un pixel. +const CORPS_TEMOIN = 100; + +const SVG = 'http://www.w3.org/2000/svg'; + +// Les éléments dont le texte ne se peint jamais : leurs nœuds de texte ne +// sont pas relevés. +const SANS_TEXTE = new Set(['script', 'style', 'template', 'noscript']); + +// Les éléments qui n'existent que pour porter un texte : rendus vides, sans +// image ni nom annoncé, ils trahissent un libellé manquant (§ 19.11). +const PORTEURS_DE_LIBELLE = 'button, label, legend, caption, th, h1, h2, h3, h4, h5, h6, p, li, dt, dd, summary, option, a'; + +// Ce qui donne un nom à un élément sans texte. +const NOMMANTS = ['aria-label', 'aria-labelledby', 'title']; + +// Les clés de la traduction : un texte qui en égale une est une clé brute. +const CLES = new Set(creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]).cles()); + +// Les jetons de couleur que la feuille des jetons déclare, dans l'ordre de +// leur première déclaration ; les jetons des textes du registre (TEXTES) +// d'abord, qui nomment une couleur partagée par plusieurs jetons. +const JETONS = (() => { + const declares = [...new Set([...JETONS_CSS.matchAll(/(--couleur-[a-z0-9-]+)\s*:/g)].map(([, nom]) => nom))]; + const deTexte = TEXTES.map(({ jeton }) => jeton); + return Object.freeze([...deTexte.filter((jeton) => declares.includes(jeton)), ...declares.filter((jeton) => !deTexte.includes(jeton))]); +})(); + +// Les captures déclarées, lues une fois de node. +let declarations = null; +async function declaration(fichier) { + declarations ??= commands.declarationsCaptures(); + const { captures } = await declarations; + return captures.find((capture) => capture.fichier === fichier) ?? null; +} + +// Les captures photographiées sur la racine du projet depuis le chargement +// du module : le contrôle de clôture du parcours. +const prises = new Set(); + +/** Les noms des captures que photographier a menées à leur terme sur la + * racine du projet, dans l'ordre. */ +export function capturesPrises() { + return [...prises]; +} + +/** Le mode des captures que fournit la configuration : 'verifier' ou + * 'ecrire'. */ +export function modeFourni() { + return inject('captures')?.mode ?? 'verifier'; +} + +/** + * Les textes du conteneur, en ordre de document : chaque nœud de texte hors + * des éléments qui ne se peignent jamais, ses blancs réduits à une espace, + * vides écartés (§ 19.5). + * + * @param {Element} racine + * @returns {string[]} + */ +export function texteReleve(racine) { + const textes = []; + const marcheur = document.createTreeWalker(racine, NodeFilter.SHOW_TEXT); + for (let noeud = marcheur.nextNode(); noeud !== null; noeud = marcheur.nextNode()) { + if (noeud.parentElement === null || noeud.parentElement.closest('script, style, template, noscript') !== null) continue; + if (SANS_TEXTE.has(noeud.parentElement.localName)) continue; + const texte = noeud.textContent.replace(/\s+/g, ' ').trim(); + if (texte !== '') textes.push(texte); + } + return textes; +} + +/** + * Les fautes de libellé du conteneur (§ 19.11) : un porteur de libellé + * rendu sans texte, sans image ni contrôle dedans, sans nom annoncé, hors + * d'une zone d'annonce ; un texte égal à une clé de la traduction ; une + * marque de la langue témoin. + * + * @param {Element} racine + * @returns {string[]} + */ +export function fautesDeLibelle(racine) { + const fautes = []; + for (const element of racine.querySelectorAll(PORTEURS_DE_LIBELLE)) { + if (!rendu(element) || element.textContent.trim() !== '') continue; + if (element.querySelector('svg, img, canvas, input, select, textarea') !== null) continue; + if (NOMMANTS.some((nom) => (element.getAttribute(nom) ?? '').trim() !== '')) continue; + if (element.closest('[aria-live], [role="status"], [role="alert"], [role="log"]') !== null) continue; + fautes.push(`élément de texte vide : ${situer(element)}`); + } + for (const texte of texteReleve(racine)) { + if (CLES.has(texte)) fautes.push(`clé brute affichée : « ${texte} »`); + if (texte.includes(MARQUE_TEMOIN.ouverture) || texte.includes(MARQUE_TEMOIN.fermeture)) { + fautes.push(`marque de la langue témoin : « ${extrait(texte)} »`); + } + } + return fautes; +} + +// La largeur rendue de la chaîne témoin dans la police de l'interface, au +// corps CORPS_TEMOIN, crénage et ligatures coupés comme longueurTemoin les +// compte, sur un posé dans le conteneur le temps de la lecture. +function largeurTemoin(racine) { + const svg = document.createElementNS(SVG, 'svg'); + const texte = document.createElementNS(SVG, 'text'); + texte.setAttribute( + 'style', + `font-family: var(--police-interface); font-size: ${CORPS_TEMOIN}px; font-weight: 400; font-kerning: none; font-variant-ligatures: none`, + ); + texte.textContent = CHAINE_TEMOIN; + svg.append(texte); + racine.append(svg); + try { + return texte.getComputedTextLength(); + } finally { + svg.remove(); + } +} + +// La couleur en clé de comparaison. +const cleCouleur = (couleur) => couleur.join(','); + +// Le jeton d'une couleur de texte dans le thème courant : le premier de +// JETONS qui la résout ; sa forme rgb(…) quand aucun ne la résout. +function jetonsDuTheme() { + const parCouleur = new Map(); + for (const jeton of JETONS) { + const cle = cleCouleur(couleurDeJeton(jeton)); + if (!parCouleur.has(cle)) parCouleur.set(cle, jeton); + } + return (couleur) => parCouleur.get(cleCouleur(couleur)) ?? rgb(couleur); +} + +// Un ratio arrondi au centième, comme le tableau des contrastes l'écrit. +const centieme = (ratio) => Math.round(ratio * 100) / 100; + +/** + * Les styles relevés dans le thème courant (§ 14.5, § 19.5), sur ce que la + * fenêtre montre — la région photographiée : pour chaque + * jeton et chaque rôle — texte, marque —, le plus petit ratio mesuré et le + * seuil du rôle, rangés par jeton puis rôle ; et les fautes : un ratio sous + * son seuil, une mesure impossible, une atténuation. + * + * @param {Element} racine + * @returns {{styles: Array<{jeton: string, role: string, ratio: number, seuil: number}>, fautes: string[]}} + */ +export function stylesDuTheme(racine) { + const textes = releverTextes(racine, { fenetre: true }); + const marques = releverMarques(racine, { fenetre: true }); + const jetonDe = jetonsDuTheme(); + const parCle = new Map(); + const retenir = (jeton, role, ratio, seuil) => { + const cle = `${jeton}\u{0}${role}`; + const vu = parCle.get(cle); + if (vu === undefined || ratio < vu.ratio) parCle.set(cle, { jeton, role, ratio, seuil }); + }; + for (const { couleur, ratio } of textes.ratios) retenir(jetonDe(couleur), 'texte', ratio, SEUIL_TEXTE); + for (const { jeton, ratio } of marques.ratios) retenir(jeton, 'marque', ratio, SEUIL_MARQUE); + const styles = [...parCle.values()] + .map((style) => ({ ...style, ratio: centieme(style.ratio) })) + .sort((a, b) => (a.jeton < b.jeton ? -1 : a.jeton > b.jeton ? 1 : a.role < b.role ? -1 : a.role > b.role ? 1 : 0)); + return { styles, fautes: [...textes.fautes, ...textes.attenuations, ...marques.fautes, ...marques.attenuations] }; +} + +// Les positions de défilement du document et de chaque élément du +// conteneur qui a défilé ; rend la fonction qui les restitue. +function retenirDefilements(racine) { + const positions = [document.scrollingElement, racine, ...racine.querySelectorAll('*')] + .filter((element) => element !== null && (element.scrollTop !== 0 || element.scrollLeft !== 0 || element.scrollHeight > element.clientHeight)) + .map((element) => [element, element.scrollTop, element.scrollLeft]); + return () => { + for (const [element, haut, gauche] of positions) { + element.scrollTop = haut; + element.scrollLeft = gauche; + } + }; +} + +const hex = (tampon) => [...new Uint8Array(tampon)].map((octet) => octet.toString(16).padStart(2, '0')).join(''); + +/** + * L'empreinte d'état (§ 8.8, § 19.5) : le SHA-256 hexadécimal de + * serialiserCharge de l'événement ouvert, lu sur le disque simulé du + * montage ; null sans événement ouvert. + * + * @param {Object} m un montage de monterApp + * @returns {Promise} + */ +export async function empreinteEtat(m) { + const { evenement, racine } = m.seance.etat(); + if (evenement === null) return null; + const texte = m.fs.contenu(racine.id, `${evenement.base}${SUFFIXES.etat}`); + if (texte === null) throw new Error(`état de ${evenement.base} absent du disque simulé`); + const { charge } = analyser(texte); + return hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialiserCharge(charge)))); +} + +// Un élément que l'étape affirme est-il visible ? Un élément HTML : visible +// de rendu.js, son centre atteint et non couvert. Un élément SVG se lit par +// la géométrie du dessin, comme ses textes au relevé des contrastes : rendu, +// et le centre de sa boîte dans le extérieur et dans la fenêtre — un +// dessin sous le pointeur ou hors d'atteinte reste visible. Rend true, ou la +// raison. +function conditionVisible(element) { + if (!(element instanceof SVGElement)) return visible(element); + if (!rendu(element)) return 'checkVisibility faux'; + return centreDansLeDessin(element) ? true : 'le centre de sa boîte est hors du dessin ou de la fenêtre'; +} + +/** + * Photographie l'écran du montage m sous le nom fichier, une capture de + * CAPTURES. Dans l'ordre, chaque contrôle échoue en nommant la capture et + * la nature de la faute : capture déclarée ; feuille sans mouvement posée ; + * conditions visibles ; collecteur vide ; aucune requête hors de la boucle + * locale ; cadre relu au CADRE, facteur d'échelle 1 ; thème clair ; chaîne + * témoin dans sa tolérance ; aucun chemin hors de la racine de travail + * dans le texte relevé ; aucune faute de libellé ; styles relevés dans les + * deux thèmes, sous leurs seuils ; empreinte d'état. L'entrée se compose, + * puis 'verifier' la compare au manifeste — un écart nomme l'étape et sa + * nature —, et 'ecrire' dépose l'image, prise avant les relevés, et + * l'entrée. + * + * @param {Object} m un montage de monterApp + * @param {string} fichier le nom de la capture, sans extension + * @param {Object} [options] + * @param {string|string[]} [options.condition] les sélecteurs que l'étape + * affirme : présents dans le conteneur, visibles + * @param {number|null} [options.graine] la graine écrite dans le scénario + * @param {'verifier'|'ecrire'} [options.mode] sinon celui de la configuration + * @param {string|null} [options.racine] un dossier d'épreuve, sinon le projet + * @returns {Promise} l'entrée, sans image ni empreinte + */ +export async function photographier(m, fichier, { condition = [], graine = null, mode = modeFourni(), racine = null } = {}) { + const echec = (nature) => new Error(`capture ${fichier} : ${nature}`); + const declaree = await declaration(fichier); + if (declaree === null) throw echec('absente de CAPTURES'); + if (!feuillePosee()) throw echec('la feuille qui annule transitions et animations n’est pas posée'); + for (const selecteur of [condition].flat()) { + const element = m.racine.querySelector(selecteur); + if (element === null) throw echec(`sélecteur absent « ${selecteur} »`); + const vu = conditionVisible(element); + if (vu !== true) throw echec(`« ${selecteur} » invisible : ${vu}`); + } + const [erreur] = m.erreurs(); + if (erreur !== undefined) throw echec(`collecteur : ${erreur.type} « ${erreur.message} »`); + const [requete] = await commands.requetesHorsBoucle(); + if (requete !== undefined) throw echec(`requête hors de la boucle locale : ${requete}`); + if (innerWidth !== CADRE.largeur || innerHeight !== CADRE.hauteur) { + throw echec(`cadre ${innerWidth} × ${innerHeight}, attendu ${CADRE.largeur} × ${CADRE.hauteur}`); + } + if (devicePixelRatio !== 1) throw echec(`facteur d’échelle ${devicePixelRatio}, attendu 1`); + const theme = document.documentElement.getAttribute('data-theme'); + if (theme !== 'clair') throw echec(`thème « ${theme} », l’image se prend en thème clair`); + const { regulier } = await m.polices(); + const attendue = longueurTemoin(regulier, CORPS_TEMOIN); + const lue = largeurTemoin(m.racine); + const tolerance = constante('TOLERANCE_CHAINE_TEMOIN'); + if (!(Math.abs(lue - attendue) / attendue <= tolerance)) { + throw echec(`chaîne témoin à ${lue} px, ${attendue} px attendus à ${tolerance} près : la police rendue n’est pas l’embarquée`); + } + + const selecteur = `[data-montage="${m.racine.dataset.montage}"]`; + const image = mode === 'ecrire' ? await commands.prendreCliche({ fichier, selecteur, racine }) : null; + + const texte = texteReleve(m.racine); + const racineNeutre = m.seance.etat().racine?.chemin ?? null; + if (racineNeutre === null) throw echec('aucune racine de travail : le texte relevé ne se contrôle pas'); + const [chemin] = await commands.cheminsHorsRacine({ texte, racine: racineNeutre }); + if (chemin !== undefined) throw echec(`chemin hors de la racine neutre : « ${chemin} »`); + const [libelle] = fautesDeLibelle(m.racine); + if (libelle !== undefined) throw echec(libelle); + + // Chaque thème se relève sur la position où l'image a été prise. + const restituer = retenirDefilements(m.racine); + const styles = {}; + for (const nom of THEMES) { + let releve; + try { + releve = await sousTheme(nom, () => stylesDuTheme(m.racine)); + } finally { + restituer(); + } + const { styles: releves, fautes } = releve; + if (fautes.length > 0) throw echec(`thème ${nom} : ${fautes.length} faute(s) de contraste ou d’atténuation :\n${fautes.join('\n')}`); + if (releves.length === 0) throw echec(`thème ${nom} : aucun style relevé`); + styles[nom] = releves; + } + + const entree = { + fichier, + etape: declaree.etape, + source: 'navigateur', + versionReelle: VERSION.affichee, + graine, + cadre: { largeur: innerWidth, hauteur: innerHeight }, + facteurEchelle: devicePixelRatio, + mode: MODE_AFFICHAGE, + theme, + instant: m.horloge.horodatage(), + texte, + styles, + etat: await empreinteEtat(m), + pdf: null, + }; + + if (mode === 'ecrire') { + await commands.deposerCapture({ fichier, entree, chemin: image.chemin, racine }); + } else if (mode === 'verifier') { + const { ecarts } = await commands.comparerCapture({ fichier, entree, racine }); + if (ecarts.length > 0) throw echec(`écart au manifeste :\n${ecarts.join('\n')}`); + } else { + throw echec(`mode inconnu « ${mode} »`); + } + if (racine === null) prises.add(fichier); + return entree; +} + +/** + * Clôt un parcours photographié (§ 19.1, § 19.5, § 19.7). declarees : les + * captures que le parcours porte, CAPTURES hors A_VENIR par défaut. Une + * épreuve du fichier en échec, qu'elle ait photographié ou non, refuse la + * clôture en se nommant : en écriture, le dossier d'attente est jeté et + * rien n'est publié. Sans échec, l'écriture publie d'un bloc et rend le + * bilan de publierCaptures ; la vérification affirme que le manifeste porte + * exactement les déclarées. + * + * @param {Object} options + * @param {string[]} options.echecs les épreuves du fichier qui ont échoué + * @param {string[]|null} [options.declarees] + * @param {'verifier'|'ecrire'} [options.mode] sinon celui de la configuration + * @param {string|null} [options.racine] un dossier d'épreuve, sinon le projet + * @param {string|null} options.racineNeutre la racine de travail du montage + * @returns {Promise<{declarees: string[], bilan: Object|null}>} + */ +export async function cloreCaptures({ echecs, declarees = null, mode = modeFourni(), racine = null, racineNeutre }) { + let liste = declarees; + if (liste === null) { + const { captures, aVenir } = await commands.declarationsCaptures(); + liste = captures.map(({ fichier }) => fichier).filter((fichier) => !aVenir.includes(fichier)); + } + if (liste.length === 0) throw new Error('captures : aucune capture déclarée — zéro capture n’est jamais un succès'); + if (echecs.length > 0) { + if (mode === 'ecrire') await commands.viderAttente({ racine }); + const natures = echecs.map((nom) => `« ${nom} »`).join(', '); + throw new Error(`captures non ${mode === 'ecrire' ? 'publiées' : 'vérifiées'} : ${echecs.length} épreuve(s) du parcours en échec : ${natures}`); + } + const memes = (a, b) => [...a].sort().join('\n') === [...b].sort().join('\n'); + if (mode === 'ecrire') { + const bilan = await commands.publierCaptures({ declarees: liste, racine, racineNeutre }); + if (!memes([...bilan.ecrites, ...bilan.gardees], liste)) throw new Error(`captures : publiées ${[...bilan.ecrites, ...bilan.gardees].join(', ')}, déclarées ${liste.join(', ')}`); + return { declarees: liste, bilan }; + } + if (mode !== 'verifier') throw new Error(`captures : mode inconnu « ${mode} »`); + const manifeste = await commands.lireManifeste({ racine }); + const portees = (manifeste?.captures ?? []).map(({ fichier }) => fichier); + if (portees.join('\n') !== liste.join('\n')) throw new Error(`captures : le manifeste porte ${portees.join(', ') || '(rien)'}, déclarées ${liste.join(', ')}`); + return { declarees: liste, bilan: null }; +} diff --git a/test/navigateur/commandes_documentation.js b/test/navigateur/commandes_documentation.js new file mode 100644 index 0000000..cbecd90 --- /dev/null +++ b/test/navigateur/commandes_documentation.js @@ -0,0 +1,478 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les commandes des captures d'écran, côté node (§ 19.4, § 19.5, § 19.7) : +// vitest.config.js les déclare dans browser.commands, et la page les appelle +// par commands de vitest/browser. Chacune reçoit d'abord le contexte de la +// commande — la page Playwright, le cadre de l'épreuve —, puis l'unique +// objet que la page lui passe ; elle rend du JSON. Aucun octet ne traverse +// en Uint8Array : le RPC des commandes sérialiserait un tableau typé en objet +// {"0": …}. La capture d'élément se prend ici, sur le cadre de l'épreuve, et +// s'écrit sur le disque sans repasser par la page. +// +// Les fichiers vivent sous une racine : celle du projet (racine null), ou +// un dossier d'épreuve que dossierEpreuve a créé, et que seul +// effacerDossierEpreuve retire. Toute autre racine est refusée. +// +// Le mode d'écriture. Chaque étape photographiée dépose, sous +// doc/captures/.attente/, son image .png et son entrée complète +// .json. publierCaptures publie d'un bloc : toutes les captures +// déclarées déposées, ni plus ni moins, chaque entrée relue et ses +// dimensions contrôlées, le manifeste assemblé et ses contrôles d'ordre et +// de machine tenus, alors seulement les images et le manifeste s'écrivent. +// Une image ne se réécrit que si l'empreinte de son étape a changé, ou si +// le fichier présent n'est plus celui que le manifeste décrit ; le +// manifeste ne se réécrit que si ses octets changent ; une image qui n'est +// plus déclarée part. Le dossier d'attente est jeté à la fin de chaque +// publication, réussie ou refusée : une publication refusée laisse le +// manifeste et les images tels qu'avant. +// +// Le mode de vérification compare l'entrée d'une étape à celle du +// manifeste (comparerCapture) et n'écrit rien. +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + A_VENIR, + CAPTURES, + CHEMIN_MANIFESTE, + DOSSIER_CAPTURES, + REPORTEES, + canoniser, + cheminImage, + cheminsHorsRacine as cheminsHors, + empreinteEtape, + fautesDImage, + fautesDeMachine, + lireEntetePng, + verifierOrdre, +} from '../../scripts/documentation/manifeste.js'; +import { MODE_AFFICHAGE, suivreRequetes } from '../chromium.js'; + +/** Les deux modes des captures : verifier compare au manifeste, ecrire + * dépose puis publie (make captures). */ +export const MODES = Object.freeze(['verifier', 'ecrire']); + +/** Les commandes que vitest.config.js déclare, toutes exportées ici. */ +export const COMMANDES = Object.freeze([ + 'requetesHorsBoucle', + 'echecsHorsBoucle', + 'declarationsCaptures', + 'cheminsHorsRacine', + 'lireManifeste', + 'prendreCliche', + 'deposerCapture', + 'comparerCapture', + 'publierCaptures', + 'viderAttente', + 'dossierEpreuve', + 'effacerDossierEpreuve', + 'empreintesDossier', + 'copierDansEpreuve', +]); + +/** + * Le mode des captures que GTT_CAPTURES demande : 'verifier' quand la + * variable est absente ou vide. + * + * @param {Record} env + * @returns {'verifier'|'ecrire'} + * @throws {Error} une autre valeur, nommée + */ +export function modeCaptures(env) { + const valeur = env.GTT_CAPTURES ?? ''; + if (valeur === '') return 'verifier'; + if (MODES.includes(valeur)) return valeur; + throw new Error(`GTT_CAPTURES : « ${valeur} » n’est ni ${MODES.map((m) => `« ${m} »`).join(' ni ')}`); +} + +// La racine du projet, deux dossiers au-dessus de ce module. +const PROJET = fileURLToPath(new URL('../..', import.meta.url)); + +// Le dossier d'attente, relatif à une racine. +const ATTENTE = `${DOSSIER_CAPTURES}/.attente`; + +// Les dossiers d'épreuve créés par dossierEpreuve et pas encore effacés. +const DOSSIERS_EPREUVE = new Set(); + +// Un nom de capture : lettres, chiffres, tirets, sans point ni barre. +const NOM_CAPTURE = /^[A-Za-z0-9][A-Za-z0-9-]*$/; + +const sha256 = (octets) => createHash('sha256').update(octets).digest('hex'); + +// La racine absolue que désigne racine : le projet pour null, un dossier +// d'épreuve vivant, sinon un refus. +function racineDe(racine) { + if (racine === null || racine === undefined) return PROJET; + if (DOSSIERS_EPREUVE.has(racine)) return racine; + throw new Error(`captures : racine refusée « ${racine} » — ni le projet, ni un dossier d’épreuve vivant`); +} + +function exigerNom(fichier) { + if (typeof fichier !== 'string' || !NOM_CAPTURE.test(fichier)) { + throw new Error(`captures : nom de capture refusé ${JSON.stringify(fichier)}`); + } +} + +// Le contenu d'un fichier, null quand il n'existe pas. +async function lireSiPresent(chemin, encodage) { + try { + return await readFile(chemin, encodage); + } catch (erreur) { + if (erreur.code === 'ENOENT') return null; + throw erreur; + } +} + +// Écrit un fichier par un fichier voisin renommé : un lecteur ne voit jamais +// un fichier à moitié écrit. +async function ecrireAtomique(chemin, contenu) { + const provisoire = `${chemin}.ecriture`; + await writeFile(provisoire, contenu); + await rename(provisoire, chemin); +} + +// Le manifeste d'une racine absolue, analysé ; null quand il n'existe pas. +async function manifesteDe(base) { + const texte = await lireSiPresent(join(base, CHEMIN_MANIFESTE), 'utf8'); + if (texte === null) return null; + try { + return JSON.parse(texte); + } catch (erreur) { + throw new Error(`${CHEMIN_MANIFESTE} illisible : ${erreur.message}`); + } +} + +/** Les requêtes hors de la boucle locale que la page a émises depuis + * l'appel précédent (test/chromium.js) ; le premier appel pose le suivi. */ +export async function requetesHorsBoucle(context) { + return suivreRequetes(context.page).relever(); +} + +/** Les échecs des requêtes hors de la boucle locale, { adresse, cause }, + * depuis l'appel précédent : la cause dit qui a refusé la connexion. */ +export async function echecsHorsBoucle(context) { + return suivreRequetes(context.page).releverEchecs(); +} + +/** Les captures déclarées (CAPTURES) et celles qui sont à venir (A_VENIR) : + * la page ne charge pas scripts/documentation/manifeste.js, qui est de node. */ +export async function declarationsCaptures() { + return { captures: CAPTURES.map((capture) => ({ ...capture })), aVenir: [...A_VENIR] }; +} + +/** Les chemins hors de la racine neutre que porte le texte relevé : + * cheminsHorsRacine de scripts/documentation/manifeste.js, sur les textes + * joints ligne à ligne. */ +export async function cheminsHorsRacine(_context, { texte, racine }) { + if (!Array.isArray(texte) || texte.some((ligne) => typeof ligne !== 'string')) { + throw new TypeError('cheminsHorsRacine : texte est une liste de chaînes'); + } + return cheminsHors(texte.join('\n'), racine); +} + +/** Le manifeste de la racine, analysé ; null quand il n'existe pas. */ +export async function lireManifeste(_context, { racine = null } = {}) { + return manifesteDe(racineDe(racine)); +} + +/** + * Photographie l'élément que désigne selecteur dans le cadre de l'épreuve + * et écrit l'image sous le dossier d'attente de la racine, à + * .png. Le sélecteur doit désigner un seul élément. + * + * @returns {Promise<{chemin: string}>} le chemin absolu de l'image + */ +export async function prendreCliche(context, { fichier, selecteur, racine = null }) { + exigerNom(fichier); + const base = racineDe(racine); + const element = context.iframe.locator(selecteur); + const nombre = await element.count(); + if (nombre !== 1) throw new Error(`capture ${fichier} : le sélecteur « ${selecteur} » désigne ${nombre} éléments, un attendu`); + const octets = await element.screenshot({ animations: 'disabled', caret: 'hide' }); + const dossier = join(base, ATTENTE); + await mkdir(dossier, { recursive: true }); + const chemin = join(dossier, `${fichier}.png`); + await ecrireAtomique(chemin, octets); + return { chemin }; +} + +/** + * Dépose l'entrée d'une étape à côté de son image, sous le dossier + * d'attente : l'image que prendreCliche a écrite à chemin, relue ; l'entrée + * complétée de image {sha256, largeur, hauteur} et de son empreinte + * d'étape. Des dimensions autres que cadre × facteur d'échelle refusent le + * dépôt. + * + * @returns {Promise<{empreinte: string}>} + */ +export async function deposerCapture(_context, { fichier, entree, chemin, racine = null }) { + exigerNom(fichier); + const base = racineDe(racine); + const attendu = join(base, ATTENTE, `${fichier}.png`); + if (typeof chemin !== 'string' || resolve(chemin) !== attendu) { + throw new Error(`capture ${fichier} : image attendue à ${relative(base, attendu)}, reçue ${JSON.stringify(chemin)}`); + } + if (entree?.fichier !== fichier) throw new Error(`capture ${fichier} : l’entrée porte le nom ${JSON.stringify(entree?.fichier)}`); + const octets = new Uint8Array(await readFile(attendu)); + const { largeur, hauteur } = lireEntetePng(octets); + const complete = { ...entree, image: { sha256: sha256(octets), largeur, hauteur } }; + const fautes = fautesDImage(complete, octets); + if (fautes.length > 0) throw new Error(`capture ${fichier} : ${fautes.map(({ faute }) => faute).join(' ; ')}`); + complete.empreinte = empreinteEtape(complete); + await ecrireAtomique(join(base, ATTENTE, `${fichier}.json`), JSON.stringify(complete)); + return { empreinte: complete.empreinte }; +} + +// Le texte JSON canonique d'une valeur, pour comparer deux champs. +const canonique = (valeur) => canoniser({ valeur }); + +// Le premier rang où deux listes de textes diffèrent, et les deux textes. +function premierEcart(avant, apres) { + const longueur = Math.max(avant.length, apres.length); + for (let rang = 0; rang < longueur; rang += 1) { + if (avant[rang] !== apres[rang]) return { rang, avant: avant[rang] ?? null, apres: apres[rang] ?? null }; + } + return null; +} + +// Un texte relevé en citation, ou « (rien) » au-delà de la fin. +const citer = (texte) => (texte === null ? '(rien)' : `« ${texte} »`); + +/** + * Compare l'entrée d'une étape à celle du manifeste de la racine (§ 19.7) : + * l'empreinte d'étape d'abord ; quand elle diffère, chaque champ qui + * diffère est nommé, le texte avec son premier écart. Rien ne s'écrit. + * + * @returns {Promise<{ecarts: string[]}>} vide quand l'empreinte est la même + */ +export async function comparerCapture(_context, { fichier, entree, racine = null }) { + exigerNom(fichier); + const manifeste = await manifesteDe(racineDe(racine)); + const nom = `${fichier} (étape ${entree?.etape})`; + if (manifeste === null) return { ecarts: [`${nom} : aucun manifeste, ${CHEMIN_MANIFESTE} — lancer make captures`] }; + const precedente = (manifeste.captures ?? []).find((capture) => capture?.fichier === fichier); + if (precedente === undefined) return { ecarts: [`${nom} : absente du manifeste — lancer make captures`] }; + if (empreinteEtape(entree) === precedente.empreinte) return { ecarts: [] }; + const ecarts = []; + const exclus = new Set(['image', 'empreinte', 'versionReelle']); + const champs = [...new Set([...Object.keys(entree), ...Object.keys(precedente)])].filter((champ) => !exclus.has(champ)).sort(); + for (const champ of champs) { + if (canonique(entree[champ] ?? null) === canonique(precedente[champ] ?? null)) continue; + if (champ === 'texte' && Array.isArray(entree.texte) && Array.isArray(precedente.texte)) { + const ecart = premierEcart(precedente.texte, entree.texte); + ecarts.push(`${nom} : écart de texte, rang ${ecart.rang} : ${citer(ecart.apres)} au lieu de ${citer(ecart.avant)}`); + } else { + ecarts.push(`${nom} : écart de ${champ}`); + } + } + if (ecarts.length === 0) ecarts.push(`${nom} : empreinte d’étape différente de celle du manifeste`); + return { ecarts }; +} + +// Les captures déposées sous le dossier d'attente : { fichier → {png, json} }. +// Un fichier d'une autre forme lève. +async function deposees(attente) { + let noms; + try { + noms = await readdir(attente); + } catch (erreur) { + if (erreur.code === 'ENOENT') return new Map(); + throw erreur; + } + const depots = new Map(); + for (const nom of noms.sort()) { + const forme = /^(.+)\.(png|json)$/.exec(nom); + if (forme === null || !NOM_CAPTURE.test(forme[1])) throw new Error(`publication : fichier inattendu dans ${ATTENTE} : ${nom}`); + const [, fichier, extension] = forme; + depots.set(fichier, { ...depots.get(fichier), [extension]: join(attente, nom) }); + } + return depots; +} + +// Le pilote du manifeste : le navigateur et sa version, le mode nommé, le +// facteur d'échelle et le cadre, communs à toutes les captures. +function piloteDe(context, captures) { + const [premiere] = captures; + for (const capture of captures) { + if (capture.facteurEchelle !== premiere.facteurEchelle || canonique(capture.cadre) !== canonique(premiere.cadre)) { + throw new Error(`publication : ${capture.fichier} n’a pas le cadre ni le facteur d’échelle de ${premiere.fichier}`); + } + } + const versionNavigateur = context.page.context().browser()?.version() ?? 'inconnue'; + return { + navigateur: 'chromium', + versionNavigateur, + mode: MODE_AFFICHAGE, + facteurEchelle: premiere.facteurEchelle, + cadre: { largeur: premiere.cadre.largeur, hauteur: premiere.cadre.hauteur }, + }; +} + +// Assemble la publication sans rien écrire : le manifeste, les images à +// écrire, les images à retirer. Lève sur la première raison de refuser. +async function preparer(context, base, declarees, racineNeutre) { + if (!Array.isArray(declarees) || declarees.length === 0) { + throw new Error('publication : aucune capture déclarée — zéro capture n’est pas zéro capture périmée'); + } + const connues = new Map(CAPTURES.map((capture) => [capture.fichier, capture])); + for (const fichier of declarees) { + if (!connues.has(fichier)) throw new Error(`publication : ${JSON.stringify(fichier)} n’est pas déclarée dans CAPTURES`); + } + if (new Set(declarees).size !== declarees.length) throw new Error('publication : une capture déclarée deux fois'); + + const depots = await deposees(join(base, ATTENTE)); + if (depots.size === 0) throw new Error('publication : aucune capture déposée — rien n’est publié'); + const completes = [...depots].filter(([, depot]) => depot.png && depot.json).map(([fichier]) => fichier); + const manquantes = declarees.filter((fichier) => !completes.includes(fichier)); + if (manquantes.length > 0) { + throw new Error(`publication : étapes sans capture déposée, rien n’est publié : ${manquantes.join(', ')}`); + } + const enTrop = [...depots.keys()].filter((fichier) => !declarees.includes(fichier)); + if (enTrop.length > 0) throw new Error(`publication : captures déposées hors des déclarées : ${enTrop.join(', ')}`); + + const ancien = await manifesteDe(base); + const anciennes = new Map((ancien?.captures ?? []).map((capture) => [capture?.fichier, capture])); + const captures = []; + const aEcrire = []; + const gardees = []; + for (const { fichier, etape } of CAPTURES.filter((capture) => declarees.includes(capture.fichier))) { + const { png, json } = depots.get(fichier); + const entree = JSON.parse(await readFile(json, 'utf8')); + const octets = new Uint8Array(await readFile(png)); + if (entree.fichier !== fichier || entree.etape !== etape) { + throw new Error(`publication : ${fichier} déposée sous le nom ${entree.fichier}, étape ${entree.etape} ; étape ${etape} déclarée`); + } + if (entree.empreinte !== empreinteEtape(entree)) throw new Error(`publication : ${fichier} : l’empreinte déposée ne répond pas à l’entrée`); + const fautes = fautesDImage(entree, octets); + if (fautes.length > 0) throw new Error(`publication : ${fichier} : ${fautes.map(({ faute }) => faute).join(' ; ')}`); + const precedente = anciennes.get(fichier); + const presente = await lireSiPresent(join(base, cheminImage(fichier))); + if (precedente?.empreinte === entree.empreinte && presente !== null && sha256(presente) === precedente.image?.sha256) { + captures.push({ ...entree, image: precedente.image }); + gardees.push(fichier); + } else { + captures.push(entree); + aEcrire.push({ fichier, octets }); + } + } + + const manifeste = { format: 1, pilote: piloteDe(context, captures), captures, reportees: REPORTEES.map((r) => ({ ...r })) }; + const aVenir = CAPTURES.map(({ fichier }) => fichier).filter((fichier) => !declarees.includes(fichier)); + const fautes = [...verifierOrdre(manifeste, { aVenir }), ...fautesDeMachine(manifeste, racineNeutre)]; + if (fautes.length > 0) { + throw new Error(`publication refusée :\n${fautes.map(({ chemin, faute }) => `${chemin} : ${faute}`).join('\n')}`); + } + + let presentes; + try { + presentes = await readdir(join(base, DOSSIER_CAPTURES)); + } catch (erreur) { + if (erreur.code !== 'ENOENT') throw erreur; + presentes = []; + } + const retirees = presentes + .filter((nom) => nom.endsWith('.png')) + .map((nom) => nom.slice(0, -'.png'.length)) + .filter((fichier) => !declarees.includes(fichier)) + .sort(); + return { texte: canoniser(manifeste), aEcrire, gardees, retirees }; +} + +/** + * Publie d'un bloc les captures déposées (§ 19.5) : declarees, les noms de + * CAPTURES que cette publication porte — les autres comptent comme à venir + * au contrôle d'ordre ; racineNeutre, la racine de travail que les textes + * relevés peuvent nommer (fautesDeMachine). Le dossier d'attente est jeté, + * que la publication réussisse ou non. + * + * @returns {Promise<{ecrites: string[], gardees: string[], retirees: string[], manifeste: boolean}>} + * manifeste : vrai quand ses octets ont changé + */ +export async function publierCaptures(context, { declarees, racine = null, racineNeutre } = {}) { + const base = racineDe(racine); + try { + const { texte, aEcrire, gardees, retirees } = await preparer(context, base, declarees, racineNeutre); + await mkdir(join(base, DOSSIER_CAPTURES), { recursive: true }); + for (const { fichier, octets } of aEcrire) await ecrireAtomique(join(base, cheminImage(fichier)), octets); + for (const fichier of retirees) await rm(join(base, cheminImage(fichier))); + const ancien = await lireSiPresent(join(base, CHEMIN_MANIFESTE), 'utf8'); + if (ancien !== texte) await ecrireAtomique(join(base, CHEMIN_MANIFESTE), texte); + return { ecrites: aEcrire.map(({ fichier }) => fichier), gardees, retirees, manifeste: ancien !== texte }; + } finally { + await rm(join(base, ATTENTE), { recursive: true, force: true }); + } +} + +/** Vide le dossier d'attente de la racine : un parcours en écriture part + * de lui vide, et le dépôt d'un passage interrompu ne se publie jamais + * avec les captures d'un autre. */ +export async function viderAttente(_context, { racine = null } = {}) { + await rm(join(racineDe(racine), ATTENTE), { recursive: true, force: true }); +} + +/** Crée un dossier d'épreuve neuf, sous le dossier temporaire du système, + * et rend son chemin absolu : une racine que les autres commandes + * acceptent jusqu'à effacerDossierEpreuve. */ +export async function dossierEpreuve() { + const dossier = await mkdtemp(join(tmpdir(), 'gtt-captures-')); + DOSSIERS_EPREUVE.add(dossier); + return dossier; +} + +/** Efface un dossier que dossierEpreuve a créé, et lui seul. */ +export async function effacerDossierEpreuve(_context, racine) { + if (!DOSSIERS_EPREUVE.has(racine)) throw new Error(`captures : « ${racine} » n’est pas un dossier d’épreuve vivant`); + DOSSIERS_EPREUVE.delete(racine); + await rm(racine, { recursive: true, force: true }); +} + +/** + * Copie un fichier d'un dossier d'épreuve sur un autre chemin du même + * dossier, les deux sous doc/captures : une image posée là où la + * publication ne l'a pas mise. La racine du projet est refusée. + * + * @param {Object} _context + * @param {{racine: string, depuis: string, vers: string}} chemins relatifs à racine + */ +export async function copierDansEpreuve(_context, { racine, depuis, vers }) { + if (!DOSSIERS_EPREUVE.has(racine)) throw new Error(`captures : « ${racine} » n’est pas un dossier d’épreuve vivant`); + const captures = join(racine, DOSSIER_CAPTURES); + const dedans = (chemin) => { + const absolu = resolve(racine, String(chemin)); + const relatif = relative(captures, absolu); + if (relatif === '' || relatif.startsWith('..') || isAbsolute(relatif)) { + throw new Error(`captures : ${JSON.stringify(chemin)} hors de ${DOSSIER_CAPTURES}`); + } + return absolu; + }; + const source = dedans(depuis); + const cible = dedans(vers); + await mkdir(dirname(cible), { recursive: true }); + await ecrireAtomique(cible, await readFile(source)); +} + +/** Les fichiers sous doc/captures de la racine, dossier d'attente compris : + * [chemin relatif à la racine, SHA-256], rangés par chemin. */ +export async function empreintesDossier(_context, { racine = null } = {}) { + const base = racineDe(racine); + const fichiers = []; + async function parcourir(dossier) { + let entrees; + try { + entrees = await readdir(dossier, { withFileTypes: true }); + } catch (erreur) { + if (erreur.code === 'ENOENT') return; + throw erreur; + } + for (const entree of entrees) { + const chemin = join(dossier, entree.name); + if (entree.isDirectory()) await parcourir(chemin); + else fichiers.push([relative(base, chemin).split('\\').join('/'), sha256(await readFile(chemin))]); + } + } + await parcourir(join(base, DOSSIER_CAPTURES)); + return fichiers.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); +} diff --git a/test/navigateur/feuilles.js b/test/navigateur/feuilles.js new file mode 100644 index 0000000..a8849d1 --- /dev/null +++ b/test/navigateur/feuilles.js @@ -0,0 +1,40 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La feuille de style que le pilotage injecte (§ 19.2, § 19.4) : elle annule +// toute transition et toute animation, l'icône de chargement de l'accueil +// comprise. Une capture d'écran prise à mi-transition serait une image +// différente à chaque exécution, et un écran qui attendrait transitionend +// ne viendrait jamais : le parcours photographié et l'épreuve transversale +// posent la même feuille, d'ici seul. +// +// La feuille posée porte l'attribut data-feuille-capture : photographier +// (captures.js) la cherche dans le document et refuse une capture sans elle. + +/** Le texte de la feuille : transitions et animations annulées sur tout + * élément et ses pseudo-éléments. */ +export const SANS_MOUVEMENT = '*,*::before,*::after{transition:none!important;animation:none!important}'; + +/** L'attribut qui désigne la feuille posée. */ +export const ATTRIBUT_FEUILLE = 'data-feuille-capture'; + +/** + * Pose la feuille dans et rend la fonction qui la retire. Deux poses + * posent deux feuilles, chacune retirée par sa propre fonction. + * + * @param {Document} [document] + * @returns {() => void} + */ +export function poserFeuille(document = globalThis.document) { + const feuille = document.createElement('style'); + feuille.setAttribute(ATTRIBUT_FEUILLE, ''); + feuille.textContent = SANS_MOUVEMENT; + document.head.append(feuille); + return () => feuille.remove(); +} + +/** Vrai quand une feuille posée par poserFeuille est dans le document, au + * texte de SANS_MOUVEMENT. */ +export function feuillePosee(document = globalThis.document) { + return [...document.head.querySelectorAll(`style[${ATTRIBUT_FEUILLE}]`)].some((feuille) => feuille.textContent === SANS_MOUVEMENT); +} diff --git a/vitest.config.js b/vitest.config.js index bd2a5aa..376be0c 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -15,9 +15,16 @@ // construction du livrable, sans seconde configuration à tenir // d'accord avec la première. Il joue les épreuves de src/ et // celles de l'outillage partagé de test/navigateur/. +// Chromium y tourne sans réseau hors de la boucle locale, au +// facteur d'échelle 1 (test/chromium.js). Les commandes des +// captures d'écran (test/navigateur/commandes_documentation.js) +// y sont déclarées, et le mode des captures, que GTT_CAPTURES +// choisit — verifier par défaut, ecrire sous make captures —, +// arrive à la page par provide (§ 19.5). import { defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser-playwright'; -import { optionsChromium } from './test/chromium.js'; +import { optionsNavigateur } from './test/chromium.js'; +import * as commandesDocumentation from './test/navigateur/commandes_documentation.js'; import { coeursDisponibles, reglagesSeries } from './test/machine.js'; // Les séries node suivent le nombre de cœurs de la machine (test/machine.js) : @@ -33,7 +40,11 @@ const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers }; // plan, version affichée, graphe des couches, version qu'App reçoit par ses // propriétés —, celle qui importe App.svelte pour en voir l'échec sous // node, et la garde du banc, qui balaie l'arbre à côté des motifs de ce -// fichier. +// fichier. S'y ajoutent les épreuves qui chargent ce fichier : il nomme les +// composants dans son motif, et son graphe porte les commandes des captures +// d'écran, qui balaient doc/captures ; la garde des lecteurs par le disque +// (test/arborescence.test.js), textuelle, les compte parmi les lecteurs des +// composants. const RELANCES_D_UN_COMPOSANT = Object.freeze([ 'test/arborescence.test.js', 'test/sources_interface.test.js', @@ -44,6 +55,9 @@ const RELANCES_D_UN_COMPOSANT = Object.freeze([ 'src/interface/plan/ecouteurs.test.js', 'src/interface/apropos.test.js', 'scripts/banc/banc.test.js', + 'test/chromium.test.js', + 'test/machine.test.js', + 'test/rapporteur.test.js', ]); // Les épreuves node qui lisent la feuille des jetons par le système de @@ -158,8 +172,10 @@ export default defineConfig({ test: { name: 'navigateur', include: ['src/**/*.navigateur.test.js', 'test/**/*.navigateur.test.js'], - browser: { enabled: true, headless: true, provider: playwright(optionsChromium(process.env)), - instances: [{ browser: 'chromium' }] } } }, + provide: { captures: { mode: commandesDocumentation.modeCaptures(process.env) } }, + browser: { enabled: true, headless: true, provider: playwright(optionsNavigateur(process.env)), + instances: [{ browser: 'chromium' }], + commands: Object.fromEntries(commandesDocumentation.COMMANDES.map((nom) => [nom, commandesDocumentation[nom]])) } } }, ], }, });