From 7c9b708cc99f6e538e16710e5224bf6dd9b39aa0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 10 Oct 2026 20:09:25 -0400 Subject: [PATCH] [FIX] badges: injective sheet file names, a reissue never overwrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two propositions with the same timestamp, or the by-table order, gave the same file name, so one badge sheet silently replaced another. The name now carries -p for a proposition and _table for the by-table order; the unmarked retained alphabetical name is unchanged. A reissue, or a name cut to fit under a long folder, takes the first free rank and never replaces a file; a folder that cannot be listed refuses first. A faulty proposition is refused with its own message. The control sheet names its source. Checked: node 3896, browser 767, long 105; captures 24 and 25 republished. --- FR --- [FIX] badges : noms de planche injectifs, une réédition n'écrase rien Deux propositions au même horodatage, ou l'ordre par table, donnaient le même nom : une planche en remplaçait une autre sans un mot. Le nom porte -p pour une proposition et _table pour l'ordre par table ; le nom du retenu alphabétique ne change pas. Une réédition, ou un nom raccourci sous un dossier long, prend le premier rang libre et n'écrase rien ; un dossier qui ne se liste pas refuse d'abord. Une proposition fautive a son refus. La feuille de contrôle nomme sa source. Vérifié : node 3896, navigateur 767, long 105 ; captures 24 et 25 republiées. Assisted-by: Claude Opus 5.5 --- doc/captures/24-badges-controle.png | Bin 44007 -> 48069 bytes doc/captures/manifeste.json | 4 +- spec.md | 62 +++++++-- src/application/libelles.test.js | 1 + src/application/libelles/planche.js | 16 +++ src/application/planche.js | 43 ++++-- src/application/planche.test.js | 43 +++++- src/application/planche_seance.js | 105 +++++++++++--- src/application/planche_seance.test.js | 145 +++++++++++++++---- src/interface/badges/modele.test.js | 10 +- src/pdf/planche.long.test.js | 2 + src/pdf/planche.test.js | 16 ++- src/stockage/noms.js | 178 +++++++++++++++++++---- src/stockage/noms.test.js | 186 +++++++++++++++++++++++++ 14 files changed, 699 insertions(+), 112 deletions(-) diff --git a/doc/captures/24-badges-controle.png b/doc/captures/24-badges-controle.png index b5cc767eadc257dd8e8b7643fbb90ebf90666a91..6fd77bd881a0abb6d65a06dbce1eff1c25a148e4 100644 GIT binary patch delta 31783 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 diff --git a/doc/captures/manifeste.json b/doc/captures/manifeste.json index d980bca..6cbf5fd 100644 --- a/doc/captures/manifeste.json +++ b/doc/captures/manifeste.json @@ -19,8 +19,8 @@ {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"1e58ff6f61c457c8a2b80606ced5ff9f07f7d5dadb5e8e95e77df40c4ae2ae73","etape":"11","etat":"011f06a5e584c50389bf75e8cfcb9eb6c90e8fb488e80a76227f08c9faf5f3a0","facteurEchelle":1,"fichier":"20-mode-lecture","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"0d66820beae96b1724c177ed6a2b949173051c8dec9c564692461aac01877c1b"},"instant":"2026-10-06T19:02: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é","Imprimer…","Import","Participants","Tables et tours","Plan","Diagnostic","Génération","Propositions","Badges","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"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"5e26088ad87296bbfbb8f6b06dd67f165f1fb927624176f50fca320302aa3aa3","etape":"12","etat":"57d0ccc945de5920a86bb8d5ded2dbc38a74bdac28324bb130db867677c0d624","facteurEchelle":1,"fichier":"22-plan-brouillon","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"61ec89a98ba17914c825634660cd43aa6ce4e65e464f9052487a513585a17e0c"},"instant":"2026-10-06T19:02: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-chaise-pleine","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":16.18,"role":"marque","seuil":3},{"jeton":"--couleur-filigrane","ratio":5.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":16.18,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"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-chaise-pleine","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":16.18,"role":"marque","seuil":3},{"jeton":"--couleur-filigrane","ratio":5.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-papier","ratio":13.44,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"rgb(28, 33, 40)","ratio":16.18,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Aperçu avant impression","Écriture","Plan de salle","Liste d'accueil","Feuilles par table","Tour imprimé","Tour 1","Tour 2","Tour 3","Listes de noms","Pages du plan","Découpage proposé","Tout sur une page","Découper en pages","Papier :","A4","Changer","Imprimer","Fermer l'aperçu","Page 888 sur 888 — colonne WW, rangée 888","au-dessus : page 888","à droite : page 888","au-dessous : page 888","à gauche : page 888","500","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","listes de noms hors d'échelle — le plan à l'échelle du bâtiment s'imprime listes masquées","Soirée d’essai","Date non fixée","Tour 1","Placement du","6 octobre 2026, 19 h 00","Version","construction de documentation","6","1","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. (libre)","10. (libre)","1.","Gaspard Marbrelle","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3.","Basile Lanterneau","4.","Bertille Cressonnier","5. (libre)","6. (libre)","7. (libre)","8. (libre)","Page 1 sur 1 — colonne A, rangée 1","2","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","listes de noms hors d'échelle — le plan à l'échelle du bâtiment s'imprime listes masquées","Soirée d’essai","Date non fixée","Tour 1","Placement du","6 octobre 2026, 19 h 00","Version","construction de documentation","BROUILLON"],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"c8901face8aa9f7ba6bcaa9d1251bbf340a268b0da6ab5c88ce0a114eb3451bd","etape":"12","etat":"57d0ccc945de5920a86bb8d5ded2dbc38a74bdac28324bb130db867677c0d624","facteurEchelle":1,"fichier":"23-plan-retenu","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"bf2e36213d7d341099d00b0c24e29846b24f316b5a77fe2d45ff82dd2b6c33d9"},"instant":"2026-10-06T19:02: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-chaise-pleine","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"--couleur-conflit-appartenance","ratio":8.92,"role":"marque","seuil":3},{"jeton":"--couleur-contour-fort","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":16.18,"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":16.18,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"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-chaise-pleine","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"--couleur-conflit-appartenance","ratio":8.92,"role":"marque","seuil":3},{"jeton":"--couleur-contour-fort","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":16.18,"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-papier","ratio":13.44,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"rgb(28, 33, 40)","ratio":16.18,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Aperçu avant impression","Écriture","Plan de salle","Liste d'accueil","Feuilles par table","Tour imprimé","Tour 1","Tour 2","Tour 3","Listes de noms","Pages du plan","Découpage proposé","Tout sur une page","Découper en pages","Papier :","A4","Changer","Imprimer","Fermer l'aperçu","Page 888 sur 888 — colonne WW, rangée 888","au-dessus : page 888","à droite : page 888","au-dessous : page 888","à gauche : page 888","500","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","listes de noms hors d'échelle — le plan à l'échelle du bâtiment s'imprime listes masquées","Soirée d’essai","Date non fixée","Tour 1","Placement du","6 octobre 2026, 19 h 02","Version","construction de documentation","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)","Page 1 sur 1 — colonne A, rangée 1","2","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","listes de noms hors d'échelle — le plan à l'échelle du bâtiment s'imprime listes masquées","Soirée d’essai","Date non fixée","Tour 1","Placement du","6 octobre 2026, 19 h 02","Version","construction de documentation"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":1123,"largeur":794},"empreinte":"eaadc0a821a0d488a381987c70b80321e8ca71a24db6e1e878438602c87b5adb","etape":"12","etat":"57d0ccc945de5920a86bb8d5ded2dbc38a74bdac28324bb130db867677c0d624","facteurEchelle":1,"fichier":"24-badges-controle","graine":4242,"image":{"hauteur":1123,"largeur":794,"sha256":"c07ee45a27e95bb9bcdbcab46c0e2ffefa389e3487d5bab393c1a9af9bbb5603"},"instant":"2026-10-06T19:03:00-04:00","mode":"pdfjs-dist-96ppp","pdf":"a04490a36ef4f19d7dc714d2d362c7e197f7233dde6907ffcf6325b8bb5c1fdb","source":"pdf","styles":{"clair":[],"sombre":[]},"texte":["0","50","100","Badge : 105 × 148 mm (A6)","Papier : A4, 210 × 297 mm","Imposition : feuille par feuille","Planches : 3","12 badges pour 13 participants, 1 exclu","Événement : Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00","Version : construction de documentation","Imprimer en recto seul, à 100 %"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":1123,"largeur":794},"empreinte":"b1e36d50d9cecd1992c21590c8a9cf9a983c30e91e2bdac99ad43e96385c3258","etape":"12","etat":"57d0ccc945de5920a86bb8d5ded2dbc38a74bdac28324bb130db867677c0d624","facteurEchelle":1,"fichier":"25-badges-planche","graine":4242,"image":{"hauteur":1123,"largeur":794,"sha256":"5375de3104896af92144a7b455746f989fc802cc320b8b6f52e1351c7718bf43"},"instant":"2026-10-06T19:03:00-04:00","mode":"pdfjs-dist-96ppp","pdf":"a04490a36ef4f19d7dc714d2d362c7e197f7233dde6907ffcf6325b8bb5c1fdb","source":"pdf","styles":{"clair":[],"sombre":[]},"texte":["Edgar Ardoisier","Société des Hérons","Tour 1 — table 1","Tour 2 — table 1","Tour 3 — table 2","Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00","Brunœil","Cercle des Saules","Tour 1 — table 1","Tour 2 — table 2","Tour 3 — table 1","Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00","Apolline Coudraie","Chœur de l’Anse","Tour 1 — table 1","Tour 2 — table 2","Tour 3 — table 1","Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00","Bertille Cressonnier","Cercle des Saules","Tour 1 — table 1","Tour 2 — table 2","Tour 3 — table 1","Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00"],"theme":"clair","versionReelle":"2026.10.05.01"} +{"cadre":{"hauteur":1123,"largeur":794},"empreinte":"749c29d0f4251b3f1d25459d2d626ad6910816fdf8e250c2ade168dfe59d3c3a","etape":"12","etat":"57d0ccc945de5920a86bb8d5ded2dbc38a74bdac28324bb130db867677c0d624","facteurEchelle":1,"fichier":"24-badges-controle","graine":4242,"image":{"hauteur":1123,"largeur":794,"sha256":"4731d411c33a6fa9a889acbfe92d1460a988c6f202f96578286b7d0e02109eb9"},"instant":"2026-10-06T19:03:00-04:00","mode":"pdfjs-dist-96ppp","pdf":"2f0f58fece0ba41d5a0825c83f30dea7931bc9b665e3890b4fe4f371d90559f4","source":"pdf","styles":{"clair":[],"sombre":[]},"texte":["0","50","100","Badge : 105 × 148 mm (A6)","Papier : A4, 210 × 297 mm","Imposition : feuille par feuille","Planches : 3","12 badges pour 13 participants, 1 exclu","Événement : Soirée d’essai","Source : le placement retenu","Placement du 6 octobre 2026 à 19:02:00","Version : construction de documentation","Imprimer en recto seul, à 100 %"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":1123,"largeur":794},"empreinte":"632c33170b03303a4d6314e6f3cbaf2da81f335a3a2397d94e90bf7aa202d688","etape":"12","etat":"57d0ccc945de5920a86bb8d5ded2dbc38a74bdac28324bb130db867677c0d624","facteurEchelle":1,"fichier":"25-badges-planche","graine":4242,"image":{"hauteur":1123,"largeur":794,"sha256":"5375de3104896af92144a7b455746f989fc802cc320b8b6f52e1351c7718bf43"},"instant":"2026-10-06T19:03:00-04:00","mode":"pdfjs-dist-96ppp","pdf":"2f0f58fece0ba41d5a0825c83f30dea7931bc9b665e3890b4fe4f371d90559f4","source":"pdf","styles":{"clair":[],"sombre":[]},"texte":["Edgar Ardoisier","Société des Hérons","Tour 1 — table 1","Tour 2 — table 1","Tour 3 — table 2","Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00","Brunœil","Cercle des Saules","Tour 1 — table 1","Tour 2 — table 2","Tour 3 — table 1","Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00","Apolline Coudraie","Chœur de l’Anse","Tour 1 — table 1","Tour 2 — table 2","Tour 3 — table 1","Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00","Bertille Cressonnier","Cercle des Saules","Tour 1 — table 1","Tour 2 — table 2","Tour 3 — table 1","Soirée d’essai","Placement du 6 octobre 2026 à 19:02:00"],"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"}, diff --git a/spec.md b/spec.md index a12bf43..d1f78ac 100644 --- a/spec.md +++ b/spec.md @@ -2327,8 +2327,10 @@ sur une page ; **elle ne se découpe pas**, ses pages de suite non plus. Elle porte une **échelle graphique de 100 mm, repérée tous les 10 mm**, sur sa première page, les dimensions annoncées du badge, le format de papier, l'imposition retenue, le nombre de planches, le compte de badges produits et le -compte attendu, le nom et la date de l'événement, l'horodatage du placement, la -version (§ 18.6), la consigne d'impression — recto seul, à 100 % —, les +compte attendu, le nom et la date de l'événement, la source — le placement +retenu, ou la proposition imprimée par son numéro, que l'horodatage ne +distingue pas des autres propositions de sa génération —, l'horodatage du +placement, la version (§ 18.6), la consigne d'impression — recto seul, à 100 % —, les caractères que la police n'a pas, par leur point de code (§ 11.5), et le filigrane BROUILLON s'il y a lieu, sur chacune de ses pages. L'opérateur la mesure sous une règle avant d'engager la @@ -2498,20 +2500,51 @@ la fin de la pile finale, ce qui est la propriété qui compte. Les deux impositions produisent la même pile finale par deux gestes différents ; prendre l'une pour l'autre oblige à retrier la totalité. Le logiciel inscrit l'imposition retenue **dans le nom du fichier produit**, avec le nom de -l'événement, le format de papier et l'horodatage du placement, parce que c'est la -seule chose qui survit à l'envoi de la planche à quelqu'un d'autre. Le nom suit -un gabarit fixe : +l'événement, le format de papier, la source, l'ordre et l'horodatage du +placement, parce que c'est la seule chose qui survit à l'envoi de la planche à +quelqu'un d'autre. Le nom suit un gabarit fixe : ``` -.badges[-]___.pdf +.badges[-p][-]__[_table]_[_].pdf ``` -— la base des fichiers de l'événement (§ 8.6), le nombre k de badges d'une -réédition, le papier, l'imposition, puis le jour et l'heure de l'horodatage du -placement, décalage retiré, ou `horodatage-inconnu` quand le placement n'en -porte pas. Il ne contient aucun deux-points, que Windows refuse, et tient sous la -borne de chemin du § 8.6 ; même événement, même papier, même imposition, même -placement : même nom. +— la base des fichiers de l'événement (§ 8.6), raccourcie et suivie d'un point +quand le chemin dépasserait la borne (ci-dessous) ; l'identifiant de la proposition +imprimée, rien pour le retenu ; le nombre k de badges d'une réédition ; le +papier ; l'imposition ; `_table` pour l'ordre par table, rien pour l'ordre +alphabétique ; le jour et l'heure de l'horodatage du placement, décalage +retiré, ou `horodatage-inconnu` quand le placement n'en porte pas ; enfin le +rang n d'un nom déjà pris dans le dossier. Il ne contient aucun deux-points, +que Windows refuse. + +**Le nom est injectif sur ce qui change la pile.** Une génération date toutes +les propositions qu'elle ajoute du même instant (§ 8.9) : sans leur +identifiant, deux propositions donneraient le même nom, et la seconde planche +remplacerait la première sans rien dire. L'ordre par table change la pile +autant que l'imposition, et se lit donc dans le nom de la même façon. Même +événement, même source, même papier, même imposition, même ordre, même +placement : même nom, et la planche entière remplace la sienne, qui imprime la +même pile. **Une réédition ne remplace jamais un fichier** : deux rééditions de +trois personnes différentes partagent tout le reste du nom, et la seconde prend +le premier rang libre, `_2`, `_3`…, comme le dossier daté de la corbeille +(§ 8.7). Une planche dont la base se raccourcit (ci-dessous) prend aussi le +premier rang libre : la base d'un événement et celle de sa copie (§ 8.7), qui +partagent propositions et horodatages, peuvent se raccourcir en une même tête. +Un dossier qui ne se liste pas refuse ces planches plutôt que de risquer un +remplacement, et offre « Enregistrer sous… ». + +**Le nom tient sous la borne de chemin du § 8.6.** Sans marque, son plus long +suffixe tient dans la réserve que la borne fait à chaque base. Les marques et le +rang l'allongent : quand le chemin complet dépasserait la borne, la base se +raccourcit dans le nom de la planche, sans couper un caractère, espaces et points +finaux retirés, sans laisser un nom réservé (§ 8.6), suivie d'un point ; le reste +du nom demeure entier. Aucune base ne finit par un point, que Windows retire de +la fin d'un nom et que la dérivation retire donc aussi (§ 8.6) : un nom +raccourci ne se confond avec aucun nom entier, et la planche entière d'une base +qui tient ne remplace jamais une planche raccourcie. Le nom raccourci dépend de +la racine du dossier qui le reçoit. Quand même un caractère de la base ne tient +pas, le nom garde sa base, l'écriture refusée se dit, et « Enregistrer sous… » +dépose la planche ailleurs. **Sous `electron`, le fichier se dépose à côté des fichiers d'événement** (§ 8.6), par une écriture atomique, et le logiciel en nomme le chemin ; une écriture @@ -2524,7 +2557,10 @@ nomme le fichier, sans chemin. proposition choisie dans la section des propositions ; l'annonce nomme la source. Une planche tirée d'une proposition sort **toujours** en BROUILLON (§ 11.7), à l'horodatage de cette proposition : elle n'est pas le plan retenu. Sans retenu ni -proposition désignée, la production se refuse et renvoie aux propositions. +proposition désignée, la production se refuse et renvoie aux propositions. Une +proposition qui se contredit se refuse aussi, et renvoie aux propositions : la +version précédente du fichier, le remède d'un retenu qui se contredit, ne se +reprend pas pour elle, puisque le retenu est sain. **Produire la planche n'est pas une modification.** Elle n'écrit aucun fichier d'événement et n'ajoute aucune entrée d'historique (§ 8.2), donc elle reste diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index b83156d..81c6fcc 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -542,6 +542,7 @@ const EXEMPLES = new Map([ ['ENTREE_CHANGER_CIBLE_REDONDANCE', { cible: 1200 }], // Planche de badges ['PLANCHE_SANS_PLACEMENT', { remede: { geste: 'ouvrirSection', section: 'propositions' } }], + ['PROPOSITION_FAUTIVE', { id: 2, remede: { geste: 'ouvrirSection', section: 'propositions' } }], [ 'PLANCHE_DERIVE', { diff --git a/src/application/libelles/planche.js b/src/application/libelles/planche.js index 24a7528..64ccc03 100644 --- a/src/application/libelles/planche.js +++ b/src/application/libelles/planche.js @@ -50,6 +50,12 @@ export const TABLE = new Map([ 'PLANCHE_SANS_PLACEMENT', () => "Aucun placement à imprimer : retenez une proposition, ou choisissez-en une parmi les propositions.", ], + [ + 'PROPOSITION_FAUTIVE', + ({ id }) => + `La proposition n° ${id} se contredit, la planche ne se produit pas : ` + + 'choisissez-en une autre parmi les propositions, ou relancez la génération.', + ], [ 'PLANCHE_DERIVE', ({ raisons }, texte) => @@ -126,6 +132,16 @@ export const TABLE = new Map([ (exclus === 0 ? '' : `, ${compte(exclus, 'exclu', 'exclus')}`), ], ['planche.controle.evenement', (evenement, texte) => `Événement : ${texte('planche.pied.evenement', evenement)}`], + // La source imprimée : le placement retenu, ou la proposition par son + // numéro, que rien d'autre sur la page ne distingue d'une autre proposition + // de la même génération. + [ + 'planche.controle.source', + ({ source, proposition }) => + source === 'proposition' + ? `Source : la proposition n° ${proposition}, qui n\u{2019}est pas le placement retenu` + : 'Source : le placement retenu', + ], ['planche.controle.version', ({ version }) => `Version : ${version}`], ['planche.controle.consigne', 'Imprimer en recto seul, à 100 %'], ['planche.controle.reedition', ({ n }) => `Réédition : ${compte(n, 'badge', 'badges')}`], diff --git a/src/application/planche.js b/src/application/planche.js index d2e0b5c..a193485 100644 --- a/src/application/planche.js +++ b/src/application/planche.js @@ -11,9 +11,10 @@ // en page et le PDF (src/pdf) ne composent aucun mot. // // La planche imprime une source : le placement retenu, ou une proposition -// désignée par son identifiant (Q3), qui sort toujours en BROUILLON. Elle se -// refuse sans placement, sur un placement qui se contredit ou qui ne décrit -// plus la soirée, avant de produire (§ 11.8). +// désignée par son identifiant (Q3), qui sort toujours en BROUILLON, et que +// la feuille de contrôle nomme. Elle se refuse sans placement, sur un +// placement qui se contredit ou qui ne décrit plus la soirée, avant de +// produire (§ 11.8). import { pourvoyeursDesTitres } from '../csv/import.js'; import { cleNormalisee } from '../csv/normalisation.js'; import { BADGE, PAPIERS } from '../geometrie/papiers.js'; @@ -46,6 +47,9 @@ import { versionVisible } from './version.js'; * pied: string[], piedReduit: string[]}} textes * @typedef {Object} ModelePlanche * @property {Badge[]} badges dans l'ordre de la pile + * @property {'retenu'|'proposition'} source le placement imprimé + * @property {number|null} proposition l'identifiant de la proposition + * imprimée ; null pour le retenu * @property {{nom: string, date: string|null}} evenement * @property {string|null} horodatage * @property {boolean} brouillon @@ -149,11 +153,14 @@ function placementDeSource(charge, source, proposition) { /** * Le refus qui garde la planche avant de produire (§ 11.8, Q3), le premier * dans cet ordre : PLANCHE_SANS_PLACEMENT {} — la source retenu sans retenu, - * ou la source proposition sans proposition désignée ; RETENU_FAUTIF {}, - * relayé tel que les places le lèvent, quand le placement de la source se - * contredit ; PLANCHE_DERIVE {raisons}, les raisons de dérive de la source - * nommées (raisonsNommees) quand elle ne décrit plus la soirée. null quand - * la planche se produit. Les détails portent le remède. + * ou la source proposition sans proposition désignée ; quand le placement + * de la source se contredit, RETENU_FAUTIF {}, relayé tel que les places le + * lèvent, remède la version précédente du fichier, pour le retenu, et + * PROPOSITION_FAUTIVE {id}, remède les propositions, pour une proposition — + * le retenu, sain, ne se reprend pas pour elle ; PLANCHE_DERIVE {raisons}, + * les raisons de dérive de la source nommées (raisonsNommees) quand elle ne + * décrit plus la soirée. null quand la planche se produit. Les détails + * portent le remède. * * @param {import('../stockage/types.js').Charge} charge * @param {{source?: 'retenu'|'proposition', proposition?: number|null}} [options] @@ -166,6 +173,7 @@ export function refusPlanche(charge, { source = 'retenu', proposition = null } = const placement = placementDeSource(charge, source, proposition); if (placement === null) return { code: 'PLANCHE_SANS_PLACEMENT', details: { remede: versPropositions() } }; if (fautes(placement).length > 0) { + if (source === 'proposition') return { code: 'PROPOSITION_FAUTIVE', details: { id: proposition, remede: versPropositions() } }; return { code: 'RETENU_FAUTIF', details: { remede: { geste: 'ouvrirSecours', source: 'precedent' } } }; } const raisons = derive(placement, charge); @@ -308,7 +316,8 @@ function selectionLue(charge, selection) { * Le modèle de la planche (§ 11.4 à § 11.8) : un badge par personne non * exclue — ou par personne de la sélection, pour une réédition —, dans * l'ordre demandé, ses lignes de tour lues du placement de la source, ses - * textes composés. horodatage : horodatagePlacement de la source ; + * textes composés. source et proposition : le placement imprimé, que la + * feuille de contrôle nomme ; horodatage : horodatagePlacement de la source ; * brouillon : estBrouillon sur toute la soirée, toujours vrai pour une * proposition ; version : versionVisible, que la feuille de contrôle * imprime et le badge jamais. Les comptes sont ceux de la soirée, produits @@ -368,6 +377,8 @@ export function modelePlanche( const exclus = charge.participants.filter(({ exclu }) => exclu).length; return { badges, + source, + proposition: source === 'proposition' ? proposition : null, evenement, horodatage, brouillon: estBrouillon(charge, { source, tour: 'tous' }), @@ -389,7 +400,8 @@ const sansAbsents = (texte, absents) => [...texte].map((c) => (absents.has(c) ? * Les lignes de la feuille de contrôle (§ 11.3, § 11.5, § 11.8), page 1 de * la planche : les cotes du badge, le papier et ses cotes, l'imposition, le * nombre de planches, le compte produit et le compte attendu, l'événement - * et sa date, l'horodatage du placement, la version, la consigne + * et sa date, la source — le placement retenu, ou la proposition par son + * numéro —, l'horodatage du placement, la version, la consigne * d'impression ; BROUILLON s'il y a lieu ; pour une réédition, son compte * puis les noms réédités dans l'ordre de la pile ; les caractères absents * de la police, quand il y en a, par leur point de code. Dans toutes les @@ -402,7 +414,8 @@ const sansAbsents = (texte, absents) => [...texte].map((c) => (absents.has(c) ? * @param {number} options.planches entier ≥ 0 * @param {string[]} [options.absents] un caractère chacun, doublons admis * @returns {string[]} - * @throws {TypeError} une option hors de son domaine + * @throws {TypeError} une option hors de son domaine, une source du modèle + * hors de la sienne */ export function textesControle(modele, { papier, imposition, planches, absents = [] }) { if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) { @@ -418,7 +431,12 @@ export function textesControle(modele, { papier, imposition, planches, absents = if (!Array.isArray(absents) || !absents.every(unCaractere)) { throw new TypeError(`absents : caractères attendus, reçu ${JSON.stringify(absents)}`); } - const { badges, evenement, horodatage, brouillon, comptes, reedition, version } = modele; + const { badges, source, proposition, evenement, horodatage, brouillon, comptes, reedition, version } = modele; + const sourceBienFormee = + (source === 'retenu' && proposition === null) || (source === 'proposition' && Number.isSafeInteger(proposition)); + if (!sourceBienFormee) { + throw new TypeError(`modele : source et proposition attendues, reçu ${JSON.stringify({ source, proposition })}`); + } const lignes = [ libelle('planche.controle.badge', BADGE), libelle('planche.controle.papier', { papier, ...PAPIERS[papier] }), @@ -426,6 +444,7 @@ export function textesControle(modele, { papier, imposition, planches, absents = libelle('planche.controle.planches', { planches }), libelle('planche.controle.comptes', comptes), libelle('planche.controle.evenement', evenement), + libelle('planche.controle.source', { source, proposition }), horodatage === null ? libelle('planche.pied.horodatageInconnu') : libelle('planche.pied.horodatage', { horodatage }), libelle('planche.controle.version', { version }), libelle('planche.controle.consigne'), diff --git a/src/application/planche.test.js b/src/application/planche.test.js index aedb23a..e1f0db7 100644 --- a/src/application/planche.test.js +++ b/src/application/planche.test.js @@ -422,6 +422,8 @@ describe('planche : la source imprimée, le retenu ou une proposition (Q3)', () assert.deepEqual(badgeDe(tiree, 1).textes.lignes, ['Tour 1 — table 7', 'Tour 2 — table 3']); assert.deepEqual(badgeDe(retenu, 1).textes.lignes, ['Toute la soirée — table 7']); assert.notDeepEqual(tiree, retenu); + assert.deepEqual([retenu.source, retenu.proposition], ['retenu', null]); + assert.deepEqual([tiree.source, tiree.proposition], ['proposition', 2]); const sansChamp = avecProposition(); delete sansChamp.propositions[0].horodatage; assert.equal(modele(sansChamp, { source: 'proposition', proposition: 2 }).horodatage, null); @@ -456,15 +458,31 @@ describe('planche : les refus avant de produire (§ 11.8, Q3)', () => { ); }); - test('une source qui se contredit : RETENU_FAUTIF, tel que le relaient les places', () => { + test('un retenu qui se contredit : RETENU_FAUTIF, tel que le relaient les places, remède la version précédente du fichier', () => { const attendu = { code: 'RETENU_FAUTIF', details: { remede: { geste: 'ouvrirSecours', source: 'precedent' } } }; const fautif = aReserve(); fautif.retenu.placement[0].sieges[1] = [3, 3]; assert.deepEqual(refusPlanche(fautif), attendu); + assert.throws(() => modele(fautif), (erreur) => erreur instanceof ErreurCommande && erreur.code === 'RETENU_FAUTIF'); + }); + + test('une proposition qui se contredit : PROPOSITION_FAUTIVE la nomme, remède les propositions ; le retenu sain se produit, et ne la juge pas', () => { const proposee = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[1, 5], [3, 4]], reserve: [2] }]]] }); proposee.propositions[0].placement[1].reserve = []; + const attendu = { code: 'PROPOSITION_FAUTIVE', details: { id: 2, remede: VERS_PROPOSITIONS } }; assert.deepEqual(refusPlanche(proposee, { source: 'proposition', proposition: 2 }), attendu); - assert.throws(() => modele(fautif), (erreur) => erreur instanceof ErreurCommande && erreur.code === 'RETENU_FAUTIF'); + assert.throws( + () => modele(proposee, { source: 'proposition', proposition: 2 }), + (erreur) => erreur instanceof ErreurCommande && erreur.code === 'PROPOSITION_FAUTIVE' && erreur.details.id === 2, + ); + assert.equal(refusPlanche(proposee), null); + const texte = libelle(attendu.code, attendu.details); + assert.match(texte, /proposition n° 2\b/); + assert.doesNotMatch(texte, /retenu|version précédente/); + // Un retenu fautif n'empêche pas d'imprimer une proposition saine. + const retenuFautif = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[1, 5], [3, 4]], reserve: [2] }]]] }); + retenuFautif.retenu.placement[0].sieges[1] = [3, 3]; + assert.equal(refusPlanche(retenuFautif, { source: 'proposition', proposition: 2 }), null); }); test('une source en dérive — une personne ajoutée depuis — : PLANCHE_DERIVE, qui la nomme avant de produire', () => { @@ -493,6 +511,10 @@ describe('planche : les refus avant de produire (§ 11.8, Q3)', () => { fautifEtDerive.retenu.placement[0].sieges[1] = [3, 3]; fautifEtDerive.participants.push(personne(6, 'Ondine', 'Grisaille')); assert.equal(refusPlanche(fautifEtDerive).code, 'RETENU_FAUTIF'); + const fautiveEtDerive = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[1, 5], [3, 4]], reserve: [2] }]]] }); + fautiveEtDerive.propositions[0].placement[1].reserve = []; + fautiveEtDerive.participants.push(personne(6, 'Ondine', 'Grisaille')); + assert.equal(refusPlanche(fautiveEtDerive, { source: 'proposition', proposition: 2 }).code, 'PROPOSITION_FAUTIVE'); const sansPlacementEtDerive = aReserve({ plan: null }); sansPlacementEtDerive.participants.push(personne(6, 'Ondine', 'Grisaille')); assert.equal(refusPlanche(sansPlacementEtDerive).code, 'PLANCHE_SANS_PLACEMENT'); @@ -632,6 +654,23 @@ describe('planche : la feuille de contrôle (§ 11.3, § 11.5, § 11.8)', () => } }); + test('la source se nomme : le placement retenu, ou la proposition par son numéro, avant l’horodatage du placement', () => { + const charge = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[3, 4], [1, 5]], reserve: [2] }]]] }); + const options = { papier: 'A4', imposition: 'feuille', planches: 2 }; + const retenu = textesControle(modele(charge), options); + const tiree = textesControle(modele(charge, { source: 'proposition', proposition: 2 }), options); + assert.ok(retenu.includes('Source : le placement retenu'), JSON.stringify(retenu)); + const ligne = 'Source : la proposition n° 2, qui n\u{2019}est pas le placement retenu'; + assert.ok(tiree.includes(ligne), JSON.stringify(tiree)); + assert.equal(tiree.indexOf(ligne) + 1, tiree.indexOf(libelle('planche.pied.horodatage', { horodatage: H_PROPOSITION }))); + assert.ok(!retenu.some((texte) => texte.includes('proposition')), JSON.stringify(retenu)); + // Une source hors de son domaine lève. + const m = modele(charge); + for (const mauvaise of [{ source: undefined }, { source: 'plan' }, { source: 'proposition', proposition: null }, { source: 'retenu', proposition: 2 }]) { + assert.throws(() => textesControle({ ...m, ...mauvaise }, options), TypeError, JSON.stringify(mauvaise)); + } + }); + test('une réédition s’annonce, « Réédition : 3 badges », puis nomme les trois dans l’ordre de la pile', () => { const m = modele(aReserve(), { selection: [2, 5, 4] }); const lignes = textesControle(m, { papier: 'A4', imposition: 'feuille', planches: 1 }); diff --git a/src/application/planche_seance.js b/src/application/planche_seance.js index 7e1e19e..32909aa 100644 --- a/src/application/planche_seance.js +++ b/src/application/planche_seance.js @@ -21,10 +21,20 @@ // et, pour une réédition, la sélection. Les refus s'ordonnent : ceux du // placement (refusPlanche), puis ceux de la mise en page, nommant la // personne du badge refusé, puis le papier à choisir. +// +// Le nom du fichier (nomPlanche) porte tout ce qui change la pile : la +// source, l'imposition, l'ordre, le placement. Une réédition, ou une +// planche dont la borne du chemin raccourcit la base (nomPlancheExigeListe), +// ne remplace jamais un fichier du dossier : son nom prend le premier rang +// libre parmi les entrées que le dossier porte, listées à chaque +// préparation et à chaque production. Un dossier qui ne se liste pas laisse +// à l'annonce le nom sans dossier, et refuse la production +// PLANCHE_NON_ECRITE avant d'écrire, plutôt que de risquer de remplacer une +// planche précédente. import { PAPIERS } from '../geometrie/papiers.js'; import { nombrePlanches } from '../pdf/imposition.js'; import { ErreurStockage } from '../stockage/erreurs.js'; -import { nomPlanche } from '../stockage/noms.js'; +import { nomPlanche, nomPlancheExigeListe } from '../stockage/noms.js'; import { ErreurCommande, refus } from './erreurs.js'; import { libelle } from './libelles.js'; import { IMPOSITIONS, ORDRES, modelePlanche, refusPlanche, refusProduction, textesControle } from './planche.js'; @@ -118,7 +128,7 @@ export function creerPlanches({ polices, provenance, version }) { async function etudier({ charge, papier }, demande) { const { source, proposition, ordre, selection, imposition } = demandeLue(demande); const rejet = refusPlanche(charge, { source, proposition }); - if (rejet !== null) return { refus: rejet, modele: null, absents: [], papier, imposition, source }; + if (rejet !== null) return { refus: rejet, modele: null, absents: [], papier, imposition, ordre, source }; if (typeof provenance !== 'string') throw new TypeError('provenance : le mode de construction attendu'); const modele = modelePlanche(charge, { source, proposition, ordre, selection, provenance, version }); const octets = await lesPolices(); @@ -133,18 +143,63 @@ export function creerPlanches({ polices, provenance, version }) { } else if (papier === null) { leRefus = papierRequis(); } - return { refus: leRefus, modele, absents: analyse.absents, papier, imposition, source }; + return { refus: leRefus, modele, absents: analyse.absents, papier, imposition, ordre, source }; } - // Le nom du fichier d'une étude, null sans papier. - const nomDe = (base, { modele, papier, imposition }) => - papier === null ? null : nomPlanche(base, { papier, imposition, horodatage: modele.horodatage, reedition: modele.reedition }); + // Les options de nomPlanche pour une étude, sous le dossier donné. + const optionsDe = ({ modele, papier, imposition, ordre }, dossier) => ({ + papier, + imposition, + ordre, + horodatage: modele.horodatage, + reedition: modele.reedition, + proposition: modele.proposition, + dossier, + }); - // Les octets de la planche d'une demande, et son nom ; le refus de - // l'étude lève, ErreurCommande, avant que rien ne se produise. - async function octetsDe(lecture, demande) { + // Le nom du fichier d'une étude, sous le dossier donné (nomPlanche) ; + // null sans papier. + const nomDe = (base, etude, dossier) => (etude.papier === null ? null : nomPlanche(base, optionsDe(etude, dossier))); + + // Le dossier où la planche d'une étude se dépose, tel que nomPlanche le + // lit : sa racine, son séparateur et, quand le nom se choisit parmi elles + // (nomPlancheExigeListe), les noms qu'il porte ; null sous web, où la + // planche se télécharge. Un dossier qui ne se liste pas lève + // l'ErreurStockage de la liste. + async function dossierDe({ fs, racine, base }, etude) { + if (fs.nature === 'web') return null; + const { separateur } = await fs.emplacements(); + const sansListe = { racine: racine.chemin, separateur, existants: [] }; + if (!nomPlancheExigeListe(base, optionsDe(etude, sansListe))) return sansListe; + const existants = (await fs.lister(racine, '')).map(({ nom }) => nom); + return { ...sansListe, existants }; + } + + // Le refus d'une planche qui ne se dépose pas : le nom, le dossier, la + // cause, remède « Enregistrer sous… ». Le support de l'événement n'en est + // pas rompu. + const nonEcrite = ({ chemin, dossier, cause }) => + refus('PLANCHE_NON_ECRITE', { chemin, dossier, cause: cause ?? null }, { geste: 'enregistrerSous' }); + + // Les octets de la planche d'une demande, et son nom : sous le dossier de + // la lecture quand avecDossier est vrai, sans dossier sinon. Le refus de + // l'étude lève, ErreurCommande, avant que rien ne se produise. Le dossier + // se lit avant de produire : une liste refusée lève PLANCHE_NON_ECRITE + // sans rien produire ni écrire. + async function octetsDe(lecture, demande, { avecDossier }) { const etude = await etudier(lecture, demande); if (etude.refus !== null) throw new ErreurCommande(etude.refus.code, etude.refus.details); + let dossier = null; + if (avecDossier) { + try { + dossier = await dossierDe(lecture, etude); + } catch (erreur) { + if (!(erreur instanceof ErreurStockage)) throw erreur; + const cause = erreur.details?.cause ?? erreur.code; + throw nonEcrite({ chemin: nomDe(lecture.base, etude, null), dossier: lecture.racine.chemin, cause }); + } + } + const nom = nomDe(lecture.base, etude, dossier); const { modele, papier, imposition, absents } = etude; const planches = nombrePlanches(modele.badges.length, papier); const controle = textesControle(modele, { papier, imposition, planches, absents }); @@ -157,7 +212,7 @@ export function creerPlanches({ polices, provenance, version }) { controle, filigrane: libelle('planche.brouillon'), }); - return { octets, nom: nomDe(lecture.base, etude) }; + return { octets, nom }; } return { @@ -166,7 +221,9 @@ export function creerPlanches({ polices, provenance, version }) { * décision 12). Un refus du placement rend l'annonce sans modèle : * comptes, planches et nom null, ni homonymes ni absents, le brouillon * de la source. Sans papier, refus PAPIER_REQUIS et nom null, les - * planches des deux papiers données. Rien ne s'écrit. + * planches des deux papiers données. Le nom est celui que la production + * donnerait, rang compris ; sans dossier, donc sans rang, quand le dossier + * ne se liste pas. Rien ne s'écrit. * * @throws {TypeError} une demande hors de son domaine, une séance sans * polices ou sans provenance @@ -189,6 +246,14 @@ export function creerPlanches({ polices, provenance, version }) { }; } const { modele } = etude; + let dossier = null; + if (papier !== null) { + try { + dossier = await dossierDe(lecture, etude); + } catch (erreur) { + if (!(erreur instanceof ErreurStockage)) throw erreur; + } + } return { refus: etude.refus, papier, @@ -197,7 +262,7 @@ export function creerPlanches({ polices, provenance, version }) { homonymes: modele.homonymes, absents: etude.absents, brouillon: modele.brouillon, - nom: nomDe(lecture.base, etude), + nom: nomDe(lecture.base, etude, dossier), }; }, @@ -206,14 +271,16 @@ export function creerPlanches({ polices, provenance, version }) { * d'événement, par une écriture atomique, sous electron et sur le * système d'épreuve, et rend son chemin en entier ; sous web, par * enregistrerSous, le téléchargement, et chemin null. Le refus de - * l'annonce lève, sans rien écrire. Une écriture refusée par le support - * lève PLANCHE_NON_ECRITE {chemin, dossier, cause}, remède + * l'annonce lève, sans rien écrire. Une écriture refusée par le support, + * ou un dossier qui ne se liste pas quand le nom se choisit parmi ses + * entrées, lève + * PLANCHE_NON_ECRITE {chemin, dossier, cause}, remède * « Enregistrer sous… » : le support de l'événement n'en est pas rompu. * * @throws {ErreurCommande} le refus de l'annonce ; PLANCHE_NON_ECRITE */ async produire(lecture, demande) { - const { octets, nom } = await octetsDe(lecture, demande); + const { octets, nom } = await octetsDe(lecture, demande, { avecDossier: true }); const { fs, racine } = lecture; if (fs.nature === 'web') { await fs.enregistrerSous(nom, octets); @@ -223,8 +290,7 @@ export function creerPlanches({ polices, provenance, version }) { await fs.ecrireOctetsAtomique(racine, nom, octets); } catch (erreur) { if (!(erreur instanceof ErreurStockage && erreur.code === 'ECRITURE')) throw erreur; - const { chemin, dossier, cause } = erreur.details; - throw refus('PLANCHE_NON_ECRITE', { chemin, dossier, cause }, { geste: 'enregistrerSous' }); + throw nonEcrite(erreur.details); } const { separateur } = await fs.emplacements(); const tete = racine.chemin.endsWith(separateur) ? racine.chemin : racine.chemin + separateur; @@ -233,13 +299,14 @@ export function creerPlanches({ polices, provenance, version }) { /** * Produit la planche et la confie au dialogue d'enregistrement de la - * plateforme, sous le nom de la planche : le remède d'une écriture + * plateforme, sous le nom de la planche sans rang ni coupe, que + * l'opérateur garde ou change avec le dossier : le remède d'une écriture * refusée. Rend le chemin choisi, null quand l'opérateur annule. * * @throws {ErreurCommande} le refus de l'annonce */ async enregistrerSous(lecture, demande) { - const { octets, nom } = await octetsDe(lecture, demande); + const { octets, nom } = await octetsDe(lecture, demande, { avecDossier: false }); return lecture.fs.enregistrerSous(nom, octets); }, }; diff --git a/src/application/planche_seance.test.js b/src/application/planche_seance.test.js index c4bda18..37ef318 100644 --- a/src/application/planche_seance.test.js +++ b/src/application/planche_seance.test.js @@ -11,17 +11,17 @@ // // La série surveillée borne le coût (§ 14.4, contrainte 31) : douze // productions de PDF au plus dans ce fichier, sur une soirée de quatre -// personnes — trois pour les trois modes, trois pour la stabilité des +// personnes — trois pour les trois modes, deux pour la stabilité des // octets, une réédition, deux refus d'écriture et l'enregistrement sous, -// une sous web, une proposition. L'imposition et l'ordre se lisent dans la -// place de chaque nom, et le papier, l'imposition et le nombre de planches -// dans la feuille de contrôle : la première production, sous la demande -// par défaut, en A4 — l'ordre alphabétique ; la proposition, sous la -// demande par défaut, en Lettre — feuille par feuille sur deux planches ; -// celle sous web en Lettre, en pile, par table. La réédition porte un -// caractère sans glyphe, que sa feuille de contrôle nomme. Le témoin de 257 -// ne s'analyse que pour son annonce. Les noms sont inventés ; la version -// vient du module engendré. +// une sous web, deux propositions de la même génération. L'imposition et +// l'ordre se lisent dans la place de chaque nom, et le papier, l'imposition +// et le nombre de planches dans la feuille de contrôle : la première +// production, sous la demande par défaut, en A4 — l'ordre alphabétique ; la +// proposition, sous la demande par défaut, en Lettre — feuille par feuille +// sur deux planches ; celle sous web en Lettre, en pile, par table. La +// réédition porte un caractère sans glyphe, que sa feuille de contrôle +// nomme. Le témoin de 257 ne s'analyse que pour son annonce. Les noms sont +// inventés ; la version vient du module engendré. import assert from 'node:assert/strict'; import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { H_PROPOSITION, H_RETENU, personne, soiree, table } from '../../test/fixtures/badges/cas.js'; @@ -32,9 +32,10 @@ import { lirePolices } from '../../test/polices.js'; import { PAPIERS } from '../geometrie/papiers.js'; import { cellules } from '../pdf/imposition.js'; import { serialiser } from '../stockage/canonique.js'; +import { ErreurStockage } from '../stockage/erreurs.js'; import { creer as creerDansLeDepot } from '../stockage/depot.js'; import { FORMAT } from '../stockage/document.js'; -import { SUFFIXES, nomPlanche } from '../stockage/noms.js'; +import { SUFFIXES, deriverBase, nomPlanche } from '../stockage/noms.js'; import { VERSION } from '../version.genere.js'; import { libelle } from './libelles.js'; import { creerSeance } from './seance.js'; @@ -109,8 +110,8 @@ function chargeLucioles({ personnes = null, ...champs } = {}) { } // Le nom de la planche de la soirée d'essai. -const nomDe = ({ papier = 'A4', imposition = 'feuille', horodatage = H_RETENU, reedition = null } = {}) => - nomPlanche(BASE, { papier, imposition, horodatage, reedition }); +const nomDe = ({ papier = 'A4', imposition = 'feuille', horodatage = H_RETENU, reedition = null, proposition = null, ordre } = {}) => + nomPlanche(BASE, { papier, imposition, horodatage, reedition, proposition, ordre }); // Pose l'événement de la charge sur le disque, par le dépôt ; format : // celui qu'annonce l'en-tête de l'état, FORMAT par défaut. @@ -131,7 +132,7 @@ async function deposer(fs, charge, { format = FORMAT } = {}) { // Une séance sur l'événement de la charge, ouvert en lecture, le papier // choisi quand il est donné ; la trace part ensuite, vide. -async function monter(charge = chargeLucioles(), { papier = 'A4', fs = creerFichiersSimules({ documents: DOCUMENTS }), format } = {}) { +async function monter(charge = chargeLucioles(), { papier = 'A4', fs = creerFichiersSimules({ documents: DOCUMENTS }), format, base = BASE } = {}) { await deposer(fs, charge, { format }); let tirage = 0; const seance = creerSeance({ @@ -149,7 +150,7 @@ async function monter(charge = chargeLucioles(), { papier = 'A4', fs = creerFich provenance: 'production', }); if (papier !== null) await seance.choisirPapier(papier); - await seance.ouvrir(BASE); + await seance.ouvrir(base); const trace = tracer(fs); return { fs, seance, trace }; } @@ -315,7 +316,9 @@ describe('planche dans la séance : l’annonce (§ 11.2, § 11.5, § 11.8, déc test('une proposition s’annonce BROUILLON, sous son propre horodatage ; sans placement, PLANCHE_SANS_PLACEMENT et rien du modèle', async () => { const { seance } = await monter(); const proposee = await seance.preparerPlanche({ source: 'proposition', proposition: 2 }); - assert.deepEqual([proposee.refus, proposee.brouillon, proposee.nom], [null, true, nomDe({ horodatage: H_PROPOSITION })]); + assert.deepEqual([proposee.refus, proposee.brouillon, proposee.nom], [null, true, nomDe({ horodatage: H_PROPOSITION, proposition: 2 })]); + assert.notEqual((await seance.preparerPlanche({ source: 'proposition', proposition: 1 })).nom, proposee.nom); + assert.equal((await seance.preparerPlanche({ ordre: 'table' })).nom, nomDe({ ordre: 'table' })); const sans = await monter(chargeLucioles({ plan: null, etat: 'propose' })); const annonce = await sans.seance.preparerPlanche(); @@ -360,11 +363,12 @@ describe('planche dans la séance : l’annonce (§ 11.2, § 11.5, § 11.8, déc }); describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', () => { - test('deux productions du même placement : mêmes octets, même nom ; un geste lancé pendant la seconde attend son tour ; la retouche change le nom ; défaire rend le premier', async () => { + test('deux productions du même placement : mêmes octets, même nom ; un geste lancé pendant la seconde attend son tour ; la retouche change le nom ; défaire rend la charge, donc le premier', async () => { const { fs, seance, trace } = await monter(); const premier = await seance.produirePlanche(); const octetsPremiers = fs.octets('documents', premier.nom); await seance.passerEnEcriture(); + const chargePremiere = structuredClone(seance.charge); trace.length = 0; const production = seance.produirePlanche(); @@ -382,19 +386,30 @@ describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', () assert.notEqual(horodatage, H_RETENU); assert.equal(retouche.nom, nomDe({ horodatage })); + // Défaire rend la charge entière, horodatage compris : la production, + // qui ne lit qu'elle et la demande, rend les octets que les deux + // premières ont rendus à l'identique, sous le même nom. await seance.defaire(); - const troisieme = await seance.produirePlanche(); - assert.deepEqual(troisieme, premier); - assert.deepEqual(fs.octets('documents', troisieme.nom), octetsPremiers); + assert.deepEqual(seance.charge, chargePremiere); + assert.equal((await seance.preparerPlanche()).nom, premier.nom); }); test('une réédition de trois badges : un autre nom, « -3 », et la feuille de contrôle dit « Réédition : 3 badges » et nomme le caractère sans glyphe d’« Ondine 李 »', async () => { const charge = chargeLucioles(); charge.participants[2].nom = '李'; - const { fs, seance } = await monter(charge); + const { fs, seance, trace } = await monter(charge); + // Une réédition de trois autres personnes, déjà posée sous le même nom : + // la nouvelle prend le rang libre, et la première reste. + const pris = nomDe({ reedition: { ids: [3, 1, 4] } }); + const anciens = new TextEncoder().encode('%PDF-réédition précédente'); + fs.deposerOctets('documents', pris, anciens); + const annonce = await seance.preparerPlanche({ selection: [3, 1, 4] }); const rendu = await seance.produirePlanche({ selection: [3, 1, 4] }); - assert.equal(rendu.nom, nomDe({ reedition: { ids: [3, 1, 4] } })); + assert.equal(rendu.nom, pris.replace(/\.pdf$/, '_2.pdf')); + assert.equal(annonce.nom, rendu.nom); assert.match(rendu.nom, /\.badges-3_A4_feuille_/); + assert.deepEqual(fs.octets('documents', pris), anciens); + assert.deepEqual(trace, [['ecrireOctetsAtomique', rendu.nom]]); const pdf = await lirePdf(fs.octets('documents', rendu.nom)); assert.ok(textesDe(pdf.pages[0]).includes(libelle('planche.controle.reedition', { n: 3 })), textesDe(pdf.pages[0]).join(' | ')); assert.ok(textesDe(pdf.pages[0]).includes('Réédition : 3 badges')); @@ -403,11 +418,33 @@ describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', () assert.equal(pdf.pages.length, 2); }); - test('une proposition produite porte le mot du filigrane au trait, sa feuille de contrôle aussi ; en Lettre sous la demande par défaut, feuille par feuille, dans l’ordre alphabétique', async () => { - const { fs, seance } = await monter(chargeLucioles(), { papier: 'Lettre' }); + test('deux propositions de la même génération, au même horodatage : deux fichiers ; la seconde porte le mot du filigrane au trait, sa feuille de contrôle aussi et la nomme ; en Lettre sous la demande par défaut, feuille par feuille, dans l’ordre alphabétique', async () => { + const charge = chargeLucioles(); + assert.equal(charge.propositions[0].horodatage, charge.propositions[1].horodatage); + const { fs, seance, trace } = await monter(charge, { papier: 'Lettre' }); + const premiere = await seance.produirePlanche({ source: 'proposition', proposition: 1 }); + const octetsPremiers = fs.octets('documents', premiere.nom); const rendu = await seance.produirePlanche({ source: 'proposition', proposition: 2 }); - assert.equal(rendu.nom, nomDe({ papier: 'Lettre', horodatage: H_PROPOSITION })); + assert.deepEqual( + [premiere.nom, rendu.nom], + [1, 2].map((proposition) => nomDe({ papier: 'Lettre', horodatage: H_PROPOSITION, proposition })), + ); + assert.notEqual(premiere.nom, rendu.nom); + assert.deepEqual(fs.octets('documents', premiere.nom), octetsPremiers); + assert.notDeepEqual(fs.octets('documents', rendu.nom), octetsPremiers); + const pdfs = (await fs.lister(DOC, '')).map(({ nom }) => nom).filter((nom) => nom.endsWith('.pdf')); + assert.deepEqual(pdfs, [premiere.nom, rendu.nom]); + assert.deepEqual(trace, [ + ['ecrireOctetsAtomique', premiere.nom], + ['ecrireOctetsAtomique', rendu.nom], + ]); + + // La seconde : le mot du filigrane au trait, sur ses planches et sa + // feuille de contrôle, qui la nomme ; en Lettre sous la demande par + // défaut, feuille par feuille, dans l'ordre alphabétique. const pdf = await lirePdf(fs.octets('documents', rendu.nom)); + const source = libelle('planche.controle.source', { source: 'proposition', proposition: 2 }); + assert.ok(textesDe(pdf.pages[0]).includes(source), textesDe(pdf.pages[0]).join(' | ')); assert.ok(pdf.pages.every((page) => textesDe(page).includes(FILIGRANE))); assert.equal(auTrait(pdf), true); // Deux badges par planche : feuille par feuille, la planche p porte les @@ -425,6 +462,60 @@ describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', () }); }); +describe('planche dans la séance : un nom par pile (§ 11.6)', () => { + test('une réédition dont le dossier ne se liste pas : l’annonce garde le nom sans rang, produire se refuse PLANCHE_NON_ECRITE avant d’écrire, plutôt que de risquer un remplacement', async () => { + const { fs, seance, trace } = await monter(); + const lister = fs.lister; + fs.lister = async () => { + throw new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EACCES' }); + }; + const nom = nomDe({ reedition: { ids: [2] } }); + assert.equal((await seance.preparerPlanche({ selection: [2] })).nom, nom); + await refusee(seance.produirePlanche({ selection: [2] }), 'PLANCHE_NON_ECRITE', { + chemin: nom, + dossier: DOCUMENTS, + cause: 'EACCES', + remede: { geste: 'enregistrerSous' }, + }); + assert.deepEqual(trace, []); + // Une planche entière ne liste pas le dossier : elle se nomme sans lui. + assert.equal((await seance.preparerPlanche()).nom, nomDe()); + fs.lister = lister; + }); + + test('une planche entière dont la base se raccourcit liste le dossier : elle prend le premier rang libre plutôt que de remplacer un fichier ; un dossier qui ne se liste pas la refuse avant d’écrire', async () => { + const nom = `Souper des Lucioles du printemps, \u{E9}dition sp\u{E9}ciale ${'et des Merles '.repeat(12)}`; + const fs = creerFichiersSimules({ documents: DOCUMENTS }); + const { separateur } = await fs.emplacements(); + const base = deriverBase(nom, { racine: DOCUMENTS, separateur, existantes: [] }); + const { seance, trace } = await monter(chargeLucioles({ nom }), { papier: 'Lettre', fs, base }); + const demande = { source: 'proposition', proposition: 2, ordre: 'table' }; + const options = { papier: 'Lettre', imposition: 'feuille', horodatage: H_PROPOSITION, reedition: null, proposition: 2, ordre: 'table' }; + const raccourci = nomPlanche(base, { ...options, dossier: { racine: DOCUMENTS, separateur, existants: [] } }); + assert.notEqual(raccourci, nomPlanche(base, options), 'la base se raccourcit'); + assert.equal((await seance.preparerPlanche(demande)).nom, raccourci); + // Le fichier d'un autre événement porte déjà ce nom : la planche prend le rang suivant. + fs.deposer('documents', raccourci, 'planche d’un autre événement'); + const auRang2 = nomPlanche(base, { ...options, dossier: { racine: DOCUMENTS, separateur, existants: [raccourci] } }); + assert.ok(auRang2.endsWith('_2.pdf'), auRang2); + assert.equal((await seance.preparerPlanche(demande)).nom, auRang2); + const lister = fs.lister; + fs.lister = async () => { + throw new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EACCES' }); + }; + assert.equal((await seance.preparerPlanche(demande)).nom, nomPlanche(base, options)); + await refusee(seance.produirePlanche(demande), 'PLANCHE_NON_ECRITE', { + chemin: nomPlanche(base, options), + dossier: DOCUMENTS, + cause: 'EACCES', + remede: { geste: 'enregistrerSous' }, + }); + fs.lister = lister; + assert.deepEqual(trace, []); + assert.equal(fs.contenu('documents', raccourci), 'planche d’un autre événement'); + }); +}); + describe('planche dans la séance : l’écriture refusée, enregistrer sous, le web (§ 8.6, décision 13)', () => { test('un renommage refusé : PLANCHE_NON_ECRITE nomme le fichier, le dossier et la cause, offre « Enregistrer sous… », et le support de l’événement reste intact', async () => { const { fs, seance, trace } = await monter(); @@ -475,10 +566,10 @@ describe('planche dans la séance : l’écriture refusée, enregistrer sous, le recus.push([nom, octets]); return nom; }; - const attendu = nomDe({ papier: 'Lettre', imposition: 'pile' }); + const attendu = nomDe({ papier: 'Lettre', imposition: 'pile', ordre: 'table' }); const rendu = await seance.produirePlanche({ imposition: 'pile', ordre: 'table' }); assert.deepEqual(rendu, { chemin: null, nom: attendu }); - assert.match(attendu, /_Lettre_pile_/); + assert.match(attendu, /_Lettre_pile_table_/); assert.deepEqual(trace, []); assert.equal(recus.length, 1); assert.equal(recus[0][0], attendu); diff --git a/src/interface/badges/modele.test.js b/src/interface/badges/modele.test.js index 0485cf8..51fe786 100644 --- a/src/interface/badges/modele.test.js +++ b/src/interface/badges/modele.test.js @@ -73,12 +73,14 @@ const retenue = (champs = {}) => // Les opérations de la planche d'une séance, sur la police embarquée lue // du disque, la provenance d'épreuve et la version engendrée. const planches = creerPlanches({ polices: async () => lirePolices(), provenance: 'test', version: VERSION.affichee }); -const fsInutile = Object.freeze({ nature: 'epreuve' }); +// Un dossier de travail vide : le nom de la planche ne lit que son +// séparateur et, pour une réédition, ses entrées. +const fsVide = Object.freeze({ nature: 'epreuve', emplacements: async () => ({ separateur: '/' }), lister: async () => [] }); // L'annonce que la séance rendrait sur cette charge, ce papier et cette -// demande : la lecture que la séance passe, sans système de fichiers lu. +// demande : la lecture que la séance passe, sur un dossier vide. const annoncer = (charge, papier, demande = {}) => - planches.preparer({ charge, base: 'lucioles', fs: fsInutile, racine: { id: 'documents', chemin: '/d' }, papier }, demande); + planches.preparer({ charge, base: 'lucioles', fs: fsVide, racine: { id: 'documents', chemin: '/d' }, papier }, demande); describe('les sources offertes (Q3)', () => { test('le retenu d’abord, puis la proposition demandée au plan quand elle existe ; rien sans l’un ni l’autre', () => { @@ -206,7 +208,7 @@ describe('les phrases de l’annonce (§ 11.2, § 11.5, § 11.8)', () => { assert.equal(produireBloque(null), false); assert.equal(produireBloque({ ...vide, refus: null }), false); assert.equal(produireBloque({ ...vide, refus: { code: 'PAPIER_REQUIS', details: {} } }), false); - for (const code of ['PLANCHE_SANS_PLACEMENT', 'RETENU_FAUTIF', 'PLANCHE_DERIVE', 'PLANCHE_TROP_DE_TOURS', 'PLANCHE_NOM_TROP_LONG']) { + for (const code of ['PLANCHE_SANS_PLACEMENT', 'RETENU_FAUTIF', 'PROPOSITION_FAUTIVE', 'PLANCHE_DERIVE', 'PLANCHE_TROP_DE_TOURS', 'PLANCHE_NOM_TROP_LONG']) { assert.equal(produireBloque({ ...vide, refus: { code, details: {} } }), true, code); } }); diff --git a/src/pdf/planche.long.test.js b/src/pdf/planche.long.test.js index 96cf1e7..32f63c0 100644 --- a/src/pdf/planche.long.test.js +++ b/src/pdf/planche.long.test.js @@ -54,6 +54,8 @@ function modele(n, { brouillon = false, reedition = false } = {}) { }); return { badges, + source: 'retenu', + proposition: null, evenement: EVENEMENT, horodatage: HORODATAGE, brouillon, diff --git a/src/pdf/planche.test.js b/src/pdf/planche.test.js index 515794e..1b3b524 100644 --- a/src/pdf/planche.test.js +++ b/src/pdf/planche.test.js @@ -11,8 +11,9 @@ // // La série surveillée borne le coût (§ 14.4) : trois productions sur de // petits modèles écrits à la main, que toutes les épreuves du fichier -// lisent — A4, cinq badges, feuille par feuille, brouillon, horodatage -// connu, produite deux fois ; Lettre, trois badges, en pile, retenu, +// lisent — A4, cinq badges, feuille par feuille, brouillon tiré de la +// proposition n° 7, horodatage connu, produite deux fois ; Lettre, trois +// badges, en pile, retenu, // horodatage inconnu, sous un nom d'événement plus large que la page, dont // la ligne de contrôle se replie aux espaces, reporte entier un mot qui // tient seul, puis coupe un mot qui ne tient pas seul. Les lignes de @@ -72,9 +73,11 @@ function badge(participant, nom, { appartenance = null, tours = 2, horodatage = } /** Un modèle de planche écrit à la main. */ -function modele(badges, { horodatage = HORODATAGE, brouillon = false, exclus = 0, evenement = EVENEMENT } = {}) { +function modele(badges, { horodatage = HORODATAGE, brouillon = false, exclus = 0, evenement = EVENEMENT, proposition = null } = {}) { return { badges, + source: proposition === null ? 'retenu' : 'proposition', + proposition, evenement, horodatage, brouillon, @@ -88,7 +91,7 @@ function modele(badges, { horodatage = HORODATAGE, brouillon = false, exclus = 0 const NOMS_A4 = ['Iris Ombrelle', 'Ôjgq Æ Grisaille', 'Théo Pervenche', 'Ondine Lacasse', 'Merle Bleuet']; const MODELE_A4 = modele( NOMS_A4.map((nom, i) => badge(i + 1, nom, { appartenance: i % 2 === 0 ? 'Club des Merles' : null })), - { brouillon: true, exclus: 1 }, + { brouillon: true, exclus: 1, proposition: 7 }, ); const NOMS_LETTRE = ['Aube Sarcelle', 'Côme Pervenche', 'Ysée Lacasse']; const MODELE_LETTRE = modele( @@ -448,7 +451,7 @@ describe('produirePlanche : la feuille de contrôle, page 1 (§ 11.3)', () => { } }); - test("les lignes de contrôle se lisent dans la couche de texte, dans l'ordre et entières : comptes, horodatage, version, papier", () => { + test("les lignes de contrôle se lisent dans la couche de texte, dans l'ordre et entières : comptes, source, horodatage, version, papier", () => { for (const { papier, imposition, lu, modele: m } of PRODUCTIONS) { const lignes = textesControle(m, { papier, imposition, planches: nombrePlanches(m.badges.length, papier) }); recomposer(fragmentsDeControle(lu.pages[0]), lignes); @@ -457,6 +460,9 @@ describe('produirePlanche : la feuille de contrôle, page 1 (§ 11.3)', () => { assert.ok(lusA4.includes(libelle('planche.controle.comptes', MODELE_A4.comptes))); assert.ok(lusA4.includes(libelle('planche.pied.horodatage', { horodatage: HORODATAGE }))); assert.ok(LETTRE.pages[0].textes.some(({ texte }) => texte === libelle('planche.pied.horodatageInconnu'))); + // La source se nomme : la proposition imprimée par son numéro, ou le retenu. + assert.ok(lusA4.includes('Source : la proposition n° 7, qui n\u{2019}est pas le placement retenu'), lusA4.join(' | ')); + assert.ok(LETTRE.pages[0].textes.some(({ texte }) => texte === 'Source : le placement retenu')); }); test("une ligne plus large que la page se replie dans la largeur utile : aux espaces, puis dans le mot qui ne tient pas seul ; chaque morceau aussi plein qu'il se peut", () => { diff --git a/src/stockage/noms.js b/src/stockage/noms.js index 96412bd..c7a150e 100644 --- a/src/stockage/noms.js +++ b/src/stockage/noms.js @@ -18,8 +18,10 @@ // tiendrait que pour l'état laisserait déborder le journal, le fichier // précédent et leurs fichiers d'écriture. Le nom de la planche, posé à la // racine du dossier de travail, tient sous la même borne : son plus long -// suffixe, prolongé de SUFFIXE_ECRITURE, ne dépasse pas la partie fixe de -// longueurRelativeMax. +// suffixe sans marque, prolongé de SUFFIXE_ECRITURE, ne dépasse pas la partie +// fixe de longueurRelativeMax, et un nom marqué raccourcit sa base dans le +// dossier qui le reçoit, suivie d'un point qui le distingue de tout nom +// entier. import { PAPIERS } from '../geometrie/papiers.js'; import { ErreurStockage } from './erreurs.js'; @@ -305,25 +307,126 @@ export function dossierCorbeille(horodatage, existants) { // écrit. const IMPOSITIONS = Object.freeze(['feuille', 'pile']); +// Les ordres de la pile (§ 11.6) : l'ordre par table se marque dans le nom, +// l'ordre alphabétique, celui par défaut, ne s'y écrit pas. +const ORDRES = Object.freeze(['alphabetique', 'table']); + // Une réédition compte au plus REEDITION_MAX badges, quatre chiffres : le -// plus long suffixe de la planche, « .badges-9999__feuille_.pdf », -// en tient compte sous la borne du chemin. +// plus long suffixe sans marque de la planche, +// « .badges-9999__feuille_.pdf », en tient compte sous la borne +// du chemin. const REEDITION_MAX = 9999; // Ce qui tient lieu de date et d'heure quand le placement n'en porte pas. const HORODATAGE_INCONNU = 'horodatage-inconnu'; +// Ce qui suit une base raccourcie dans le nom de la planche : un point. Une +// base que rend deriverBase ne finit jamais par un point, si bien qu'un nom +// raccourci ne se confond avec aucun nom entier : la planche entière d'une +// base qui tient ne remplace jamais la planche raccourcie d'une autre. +const MARQUE_COUPE = '.'; + +// Le dossier de travail où la planche se dépose, contrôlé : null, ou sa +// racine, son séparateur et les noms qu'il porte déjà. +function dossierLu(dossier) { + if (dossier === null) return null; + const forme = + typeof dossier === 'object' && + typeof dossier.racine === 'string' && + (dossier.separateur === '\\' || dossier.separateur === '/') && + typeof dossier.existants?.[Symbol.iterator] === 'function'; + if (!forme) throw new TypeError('nomPlanche : dossier {racine, separateur, existants} ou null attendu'); + return dossier; +} + +// Les options de nomPlanche contrôlées, et ce qu'il en tire : le dossier lu, +// le nom de rang donné, et si le nom se choisit parmi les entrées du dossier +// — une réédition, ou une base que la borne raccourcit dès le premier rang. +// Lève comme nomPlanche. +function composer( + base, + { papier, imposition, horodatage, reedition = null, proposition = null, ordre = 'alphabetique', dossier = null } = {}, +) { + if (typeof base !== 'string' || base === '') throw new TypeError(`nomPlanche : base attendue, reçu ${JSON.stringify(base)}`); + if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) { + throw new TypeError(`nomPlanche : papier inconnu ${JSON.stringify(papier)}`); + } + if (!IMPOSITIONS.includes(imposition)) throw new TypeError(`nomPlanche : imposition inconnue ${JSON.stringify(imposition)}`); + if (proposition !== null && !(Number.isSafeInteger(proposition) && proposition >= 1)) { + throw new TypeError(`nomPlanche : identifiant de proposition attendu, reçu ${JSON.stringify(proposition)}`); + } + if (!ORDRES.includes(ordre)) throw new TypeError(`nomPlanche : ordre inconnu ${JSON.stringify(ordre)}`); + const leDossier = dossierLu(dossier); + const date = horodatage === null ? HORODATAGE_INCONNU : jourEtHeure(horodatage); + let compte = ''; + if (reedition !== null) { + if (!Array.isArray(reedition?.ids)) throw new TypeError('nomPlanche : réédition sans tableau ids'); + const k = reedition.ids.length; + if (k < 1 || k > REEDITION_MAX) throw new RangeError(`nomPlanche : réédition de ${k} badges, de 1 à ${REEDITION_MAX} admis`); + compte = `-${k}`; + } + const source = proposition === null ? '' : `-p${proposition}`; + const marqueOrdre = ordre === 'table' ? '_table' : ''; + const suffixe = (rang) => + `.badges${source}${compte}_${papier}_${imposition}${marqueOrdre}_${date}${rang === 1 ? '' : `_${rang}`}.pdf`; + if (leDossier === null) return { leDossier, nomDeRang: (rang) => base + suffixe(rang), exigeListe: false }; + + // Le nom de rang donné : la base entière quand le chemin complet, en + // écriture atomique, tient sous la borne ; sinon la plus longue tête de la + // base que tenir rend, suivie de MARQUE_COUPE ; la base entière quand même + // un caractère n'y tient pas. + const { racine, separateur } = leDossier; + const unitesRacine = racine.endsWith(separateur) ? racine.length - 1 : racine.length; + const nomDeRang = (rang) => { + const fin = suffixe(rang); + const libres = LONGUEUR_CHEMIN_MAX - (unitesRacine + 1) - SUFFIXE_ECRITURE.length - fin.length; + if (base.length <= libres) return base + fin; + const tete = tenir(base, libres - MARQUE_COUPE.length); + return tete === null ? base + fin : tete + MARQUE_COUPE + fin; + }; + return { leDossier, nomDeRang, exigeListe: reedition !== null || nomDeRang(1) !== base + suffixe(1) }; +} + /** * Nom du fichier de la planche de badges (§ 11.6), posé à la racine du - * dossier de travail : .badges[-]___.pdf, - * où k est le nombre de badges d'une réédition, papier une clé de PAPIERS, - * imposition feuille ou pile, et date le jour et l'heure de l'horodatage du - * placement, AAAA-MM-JJ_HH-MM-SS, décalage retiré — « horodatage-inconnu » - * quand il manque. Le nom ne porte aucun deux-points. Même base, même - * papier, même imposition, même placement : même nom. Le plus long suffixe, - * 51 unités, prolongé de SUFFIXE_ECRITURE, tient sous la partie fixe de - * longueurRelativeMax : la base d'un événement, que deriverBase borne, laisse - * donc place à sa planche. + * dossier de travail : + * .badges[-p][-]__[_table]_[_].pdf, + * où id est l'identifiant de la proposition imprimée — rien pour le + * retenu —, k le nombre de badges d'une réédition, papier une clé de + * PAPIERS, imposition feuille ou pile, « _table » l'ordre par table — rien + * pour l'ordre alphabétique —, date le jour et l'heure de l'horodatage du + * placement, AAAA-MM-JJ_HH-MM-SS, décalage retiré, « horodatage-inconnu » + * quand il manque, et n le rang d'un nom déjà pris que le dossier départage. + * Le nom ne porte aucun deux-points. + * + * Le nom est injectif sur ce qui change la pile : deux propositions d'une + * même génération, qui partagent leur horodatage, ou les deux ordres d'un + * même placement donnent deux noms. Même base, même source, même papier, + * même imposition, même ordre, même placement : même nom, et la planche + * entière d'un placement remplace la sienne quand sa base tient entière. + * Un nom qui se choisit parmi les entrées du dossier — celui d'une + * réédition, ou celui dont la base se raccourcit (nomPlancheExigeListe) — ne + * remplace jamais un fichier : quand le dossier porte déjà ce nom, comparé + * par la majuscule de sa NFC comme Windows compare les noms, il prend le + * premier rang libre, « _2 », « _3 »…, comme le dossier daté de la + * corbeille. Deux bases qui ne diffèrent qu'au-delà de la coupe, celle d'un + * événement et celle de sa copie, se raccourcissent en une même tête : la + * seconde planche prend un rang plutôt que de remplacer la première. + * + * La borne du chemin (§ 8.6) : le plus long suffixe sans marque, 51 unités, + * prolongé de SUFFIXE_ECRITURE, tient sous la partie fixe de + * longueurRelativeMax, et la base d'un événement, que deriverBase borne, + * laisse place à sa planche. Les marques et le rang l'allongent au-delà : + * quand le dossier est donné, la base du nom se raccourcit, sans couper une + * paire de substitution, espaces et points finaux retirés, sans laisser un + * nom réservé, et prend un point à sa suite, jusqu'à ce que racine + + * séparateur + nom + SUFFIXE_ECRITURE tienne en LONGUEUR_CHEMIN_MAX ; le + * suffixe reste entier. Aucune base que rend deriverBase ne finit par un + * point : un nom raccourci ne se confond avec aucun nom entier. + * Quand même un caractère de base ne tient pas, le nom garde sa base + * entière : l'écriture refusée par le support se dit, et « Enregistrer + * sous… » la dépose ailleurs. Sans dossier — sous web, où la planche se + * télécharge, et pour le dialogue d'enregistrement —, ni rang ni coupe. * * @param {string} base celle de l'événement, que rend deriverBase * @param {Object} options @@ -332,25 +435,44 @@ const HORODATAGE_INCONNU = 'horodatage-inconnu'; * @param {string|null} options.horodatage AAAA-MM-JJTHH:MM:SS±HH:MM, ou null * @param {null|{ids: number[]}} [options.reedition] les badges réédités ; * null ou absent pour une planche entière + * @param {number|null} [options.proposition] l'identifiant de la + * proposition imprimée ; null ou absent pour le retenu + * @param {'alphabetique'|'table'} [options.ordre] 'alphabetique' par défaut + * @param {null|{racine: string, separateur: '\\'|'/', existants: Iterable}} [options.dossier] + * le dossier de travail : sa racine, son séparateur, et les noms des + * entrées qu'il porte déjà à sa racine * @returns {string} * @throws {TypeError} une base vide ou autre chose qu'une chaîne, un papier * hors de PAPIERS, une imposition hors de feuille et pile, un - * horodatage d'une autre forme, une réédition sans tableau ids + * horodatage d'une autre forme, une réédition sans tableau ids, une + * proposition qui n'est pas un entier ≥ 1, un ordre inconnu, un + * dossier d'une autre forme * @throws {RangeError} une réédition de 0 badge ou de plus de 9 999 */ -export function nomPlanche(base, { papier, imposition, horodatage, reedition = null } = {}) { - if (typeof base !== 'string' || base === '') throw new TypeError(`nomPlanche : base attendue, reçu ${JSON.stringify(base)}`); - if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) { - throw new TypeError(`nomPlanche : papier inconnu ${JSON.stringify(papier)}`); +export function nomPlanche(base, options) { + const { leDossier, nomDeRang, exigeListe } = composer(base, options); + if (!exigeListe) return nomDeRang(1); + const pris = new Set(Array.from(leDossier.existants, cle)); + let rang = 1; + let candidat = nomDeRang(rang); + while (pris.has(cle(candidat))) { + rang += 1; + candidat = nomDeRang(rang); } - if (!IMPOSITIONS.includes(imposition)) throw new TypeError(`nomPlanche : imposition inconnue ${JSON.stringify(imposition)}`); - const date = horodatage === null ? HORODATAGE_INCONNU : jourEtHeure(horodatage); - let rang = ''; - if (reedition !== null) { - if (!Array.isArray(reedition?.ids)) throw new TypeError('nomPlanche : réédition sans tableau ids'); - const k = reedition.ids.length; - if (k < 1 || k > REEDITION_MAX) throw new RangeError(`nomPlanche : réédition de ${k} badges, de 1 à ${REEDITION_MAX} admis`); - rang = `-${k}`; - } - return `${base}.badges${rang}_${papier}_${imposition}_${date}.pdf`; + return candidat; +} + +/** + * Vrai quand le nom de la planche (nomPlanche), sous le dossier donné, se + * choisit parmi les entrées du dossier : pour une réédition, ou quand la + * borne raccourcit la base. Faux sans dossier. Les entrées du dossier n'y + * sont pas lues : l'appelant liste le dossier seulement quand il le faut. + * + * @param {string} base + * @param {Object} options celles de nomPlanche + * @returns {boolean} + * @throws {TypeError|RangeError} comme nomPlanche + */ +export function nomPlancheExigeListe(base, options) { + return composer(base, options).exigeListe; } diff --git a/src/stockage/noms.test.js b/src/stockage/noms.test.js index f52e49f..4ccac56 100644 --- a/src/stockage/noms.test.js +++ b/src/stockage/noms.test.js @@ -29,6 +29,7 @@ import { dossierCorbeille, longueurRelativeMax, nomPlanche, + nomPlancheExigeListe, } from './noms.js'; // Les deux dossiers de travail possibles (§ 8.6) : le dossier du produit dans @@ -1020,6 +1021,15 @@ describe('déterminisme et pureté (§ 14.7)', () => { dossierCorbeille('2026-10-06T19:02:11-04:00', ['2026-10-06_19-02-11']), nomPlanche('Gala', { papier: 'A4', imposition: 'pile', horodatage: '2026-10-06T19:02:11-04:00', reedition: { ids: [4, 9] } }), nomPlanche('Gala', { papier: 'A4', imposition: 'feuille', horodatage: null, reedition: null }), + nomPlanche('Gala', { + papier: 'A4', + imposition: 'pile', + horodatage: '2026-10-06T19:02:11-04:00', + reedition: { ids: [4] }, + proposition: 2, + ordre: 'table', + dossier: { ...DOSSIERS[0], existants: ['gala.BADGES-p2-1_A4_pile_table_2026-10-06_19-02-11.pdf'] }, + }), ]; assert.deepEqual(sansSourcesVariables(rendre), rendre()); }); @@ -1110,6 +1120,173 @@ describe('nomPlanche : le nom du fichier de la planche (§ 11.6, § 8.6)', () => } }); + test('une proposition porte « -p » après « .badges », avant le compte d’une réédition ; le retenu, rien : son nom ne change pas', () => { + const nom = (options) => nomPlanche(BASE, { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, reedition: null, ...options }); + assert.equal(nom({ proposition: 2 }), `${BASE}.badges-p2_A4_feuille_2031-05-17_20-15-07.pdf`); + assert.equal(nom({ proposition: 2 ** 31 - 1 }), `${BASE}.badges-p2147483647_A4_feuille_2031-05-17_20-15-07.pdf`); + assert.equal(nom({ proposition: 2, reedition: { ids: [3, 8, 12] } }), `${BASE}.badges-p2-3_A4_feuille_2031-05-17_20-15-07.pdf`); + assert.equal(nom({ proposition: null }), `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`); + assert.equal(nom({}), `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`); + }); + + test('deux propositions de la même génération, donc du même horodatage : deux noms ; le retenu au même horodatage, un troisième', () => { + const nom = (proposition) => nomPlanche(BASE, { papier: 'Lettre', imposition: 'pile', horodatage: HORODATAGE, reedition: null, proposition }); + const noms = [nom(1), nom(2), nom(null)]; + assert.equal(new Set(noms).size, 3, noms.join(' | ')); + // Sans horodatage, les trois se distinguent encore. + const inconnus = [1, 2, null].map((proposition) => nomPlanche(BASE, { papier: 'A4', imposition: 'feuille', horodatage: null, proposition })); + assert.equal(new Set(inconnus).size, 3, inconnus.join(' | ')); + }); + + test('l’ordre par table porte « _table » après l’imposition ; l’ordre alphabétique, rien', () => { + const nom = (ordre) => nomPlanche(BASE, { papier: 'A4', imposition: 'pile', horodatage: HORODATAGE, reedition: null, ordre }); + assert.equal(nom('table'), `${BASE}.badges_A4_pile_table_2031-05-17_20-15-07.pdf`); + assert.equal(nom('alphabetique'), `${BASE}.badges_A4_pile_2031-05-17_20-15-07.pdf`); + assert.equal(nom(undefined), `${BASE}.badges_A4_pile_2031-05-17_20-15-07.pdf`); + assert.equal( + nomPlanche(BASE, { papier: 'Lettre', imposition: 'feuille', horodatage: null, reedition: reeditionDe(2), proposition: 4, ordre: 'table' }), + `${BASE}.badges-p4-2_Lettre_feuille_table_horodatage-inconnu.pdf`, + ); + }); + + test('une réédition ne remplace jamais un fichier du dossier : le premier rang libre, « _2 », « _3 »…, comparé sans égard à la casse ; une planche entière garde son nom', () => { + const dossier = (existants) => ({ ...DOSSIERS[0], existants }); + const options = { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, reedition: reeditionDe(3) }; + const libre = `${BASE}.badges-3_A4_feuille_2031-05-17_20-15-07.pdf`; + const rang = (n) => libre.replace(/\.pdf$/, `_${n}.pdf`); + assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([]) }), libre); + assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([`${BASE}.gtt.json`, 'corbeille']) }), libre); + assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([libre]) }), rang(2)); + assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([rang(2), libre]) }), rang(3)); + assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([libre.toUpperCase()]) }), rang(2)); + // Un rang pris ne compte pas tant que le nom sans rang est libre. + assert.equal(nomPlanche(BASE, { ...options, dossier: dossier([rang(2)]) }), libre); + // Une liste figée passe, et n'est pas modifiée. + const figee = Object.freeze([libre]); + assert.equal(nomPlanche(BASE, { ...options, dossier: { ...DOSSIERS[0], existants: figee } }), rang(2)); + assert.deepEqual([...figee], [libre]); + // La planche entière du même placement reprend son nom : même placement, même planche. + const entiere = `${BASE}.badges_A4_feuille_2031-05-17_20-15-07.pdf`; + assert.equal(nomPlanche(BASE, { ...options, reedition: null, dossier: dossier([entiere]) }), entiere); + }); + + test('le plus long nom marqué tient sous 259 unités à côté d’une base à la borne : la base s’y raccourcit, le suffixe reste entier ; le nom sans marque ne change pas', () => { + for (const dossier of DOSSIERS) { + const base = deriverBase(lettres(400), { ...dossier, existantes: [] }); + const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: reeditionDe(9999), proposition: 2 ** 31 - 1, ordre: 'table' }; + const sansDossier = nomPlanche(base, options); + const suffixe = sansDossier.slice(base.length); + assert.equal(suffixe, '.badges-p2147483647-9999_Lettre_feuille_table_2031-05-17_20-15-07.pdf'); + // Dix rééditions de suite dans le même dossier : chacune prend le rang + // libre suivant, la base raccourcie d'autant. + const pris = []; + for (let rang = 1; rang <= 10; rang += 1) { + const nom = nomPlanche(base, { ...options, dossier: { ...dossier, existants: pris } }); + const attendu = rang === 1 ? suffixe : suffixe.replace(/\.pdf$/, `_${rang}.pdf`); + assert.ok(nom.endsWith(attendu), nom); + // La tête raccourcie finit par le point de la coupe. + const tete = nom.slice(0, nom.length - attendu.length); + assert.ok(tete.length > 1 && tete.endsWith('.') && base.startsWith(tete.slice(0, -1)), tete); + const chemin = `${dossier.racine}${dossier.separateur}${nom}.ecriture`; + assert.equal(chemin.length, LONGUEUR_CHEMIN_MAX, `${dossier.racine} : ${chemin.length} unités`); + pris.push(nom); + } + assert.equal(new Set(pris).size, 10); + // Le nom du retenu, dans l'ordre alphabétique, ne bouge pas. + const retenu = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: null }; + assert.equal(nomPlanche(base, { ...retenu, dossier: { ...dossier, existants: [] } }), nomPlanche(base, retenu)); + } + }); + + test('deux événements dont les bases ne diffèrent qu’au-delà de la coupe : la planche entière du second prend un rang plutôt que de remplacer celle du premier ; un nom raccourci ne se confond pas avec un nom entier', () => { + const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: null, proposition: 12, ordre: 'table' }; + const suffixe = nomPlanche('b', options).slice(1); + assert.equal(suffixe, '.badges-p12_Lettre_feuille_table_2031-05-17_20-15-07.pdf'); + for (const dossier of DOSSIERS) { + // L'original et sa copie : la copie ne diffère que par « (2) », que la coupe retire. + const nom = `Souper des Lucioles du printemps, \u{E9}dition sp\u{E9}ciale ${lettres(400)}`; + const original = derive(nom, dossier); + const copie = derive(nom, dossier, [original]); + assert.ok(copie.endsWith(' (2)') && copie.length === original.length, copie); + const sous = (existants) => ({ ...options, dossier: { ...dossier, existants } }); + const premier = nomPlanche(original, sous([])); + assert.notEqual(premier, original + suffixe, 'la base se raccourcit'); + assert.ok(`${dossier.racine}${dossier.separateur}${premier}.ecriture`.length <= LONGUEUR_CHEMIN_MAX, premier); + // Sans le premier fichier dans le dossier, la copie prendrait le même nom : + // listé, il lui laisse le rang suivant. + assert.equal(nomPlanche(copie, sous([])), premier); + const second = nomPlanche(copie, sous([`${original}.gtt.json`, `${copie}.gtt.json`, premier])); + // Le rang allonge le suffixe : la tête se raccourcit d'autant. + const auRang2 = suffixe.replace(/\.pdf$/, '_2.pdf'); + assert.ok(second.endsWith(`.${auRang2}`) && premier.startsWith(second.slice(0, -auRang2.length - 1)), second); + assert.ok(`${dossier.racine}${dossier.separateur}${second}.ecriture`.length <= LONGUEUR_CHEMIN_MAX, second); + assert.equal(nomPlanche(copie, sous([premier.toUpperCase()])), second); + // La planche entière raccourcie ne remplace pas davantage la sienne. + assert.equal(nomPlanche(original, sous([premier])), second); + // Une base égale à la tête raccourcie se nomme entière, autrement que le nom raccourci. + const tete = premier.slice(0, premier.length - suffixe.length); + assert.ok(tete.endsWith('.'), tete); + const courte = derive(tete.slice(0, -1), dossier); + assert.equal(courte, tete.slice(0, -1)); + const entier = nomPlanche(courte, sous([premier])); + assert.equal(entier, courte + suffixe); + assert.notEqual(entier.toUpperCase(), premier.toUpperCase()); + } + }); + + test('le nom se choisit parmi les entrées du dossier pour une réédition, ou pour une base qui se raccourcit ; jamais sans dossier', () => { + const dossier = { ...DOSSIERS[0], existants: [] }; + const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: null, proposition: 12, ordre: 'table' }; + const longue = derive(lettres(400), DOSSIERS[0]); + assert.equal(nomPlancheExigeListe(BASE, { ...options, dossier }), false); + assert.equal(nomPlancheExigeListe(BASE, { ...options, reedition: reeditionDe(2), dossier }), true); + assert.equal(nomPlancheExigeListe(longue, { ...options, dossier }), true); + assert.equal(nomPlancheExigeListe(longue, { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, dossier }), false); + assert.equal(nomPlancheExigeListe(longue, options), false); + assert.equal(nomPlancheExigeListe(longue, { ...options, reedition: reeditionDe(2) }), false); + assert.throws(() => nomPlancheExigeListe(longue, { ...options, papier: 'A5', dossier }), TypeError); + }); + + test('la coupe rend un nom bien formé : ni demi-paire, ni espace ou point avant le point de la coupe, ni nom réservé, et le chemin tient sous 259 unités', () => { + const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: null, proposition: 12, ordre: 'table' }; + const suffixe = '.badges-p12_Lettre_feuille_table_2031-05-17_20-15-07.pdf'; + // Le dossier dont la racine laisse exactement libres unités à la tête du nom : + // racine, séparateur, tête, point de la coupe, suffixe, « .ecriture ». + const dossierPour = (libres) => dossierDeLongueur(259 - 1 - libres - 1 - suffixe.length - '.ecriture'.length); + const debut = (n) => `Gala ${lettres(n - 5)}`; + const L = 40; + const cas = [ + ['une paire coupée en son milieu', L, `${debut(L - 1)}\u{1F389}\u{1F389}x`, debut(L - 1)], + ['une coupe sur une espace', L, `${debut(L - 1)} f\u{EA}te`, debut(L - 1)], + ['une coupe sur un point', L, `${debut(L - 1)}.f\u{EA}te`, debut(L - 1)], + ['une coupe sur une espace et un point', L, `${debut(L - 2)} .f\u{EA}t`, debut(L - 2)], + ['une coupe qui laisserait CON', 3, 'CON soir', 'CO'], + ['une coupe qui laisserait COM1', 4, 'COM1 soi', 'COM'], + ]; + for (const [quoi, libres, base, attendue] of cas) { + const dossier = dossierPour(libres); + assert.equal(derive(base, dossier), base, `${quoi} : la base se dérive telle quelle`); + const nom = nomPlanche(base, { ...options, dossier: { ...dossier, existants: [] } }); + assert.equal(nom, `${attendue}.${suffixe}`, quoi); + const tete = nom.slice(0, nom.length - suffixe.length - 1); + assert.ok(!/[\ud800-\udfff]/u.test(tete), `${quoi} : demi-paire dans ${JSON.stringify(tete)}`); + assert.ok(!/[ .]$/.test(tete), `${quoi} : ${JSON.stringify(tete)} finit par une espace ou un point`); + assert.equal(estReserve(nom), false, `${quoi} : ${nom}`); + assert.ok(`${dossier.racine}${dossier.separateur}${nom}.ecriture`.length <= LONGUEUR_CHEMIN_MAX, quoi); + } + }); + + test('une base qui ne laisse rien à couper sous une racine trop longue garde son nom entier : l’écriture refusée offrira « Enregistrer sous… »', () => { + // La racine la plus longue où « Gala » se dérive encore. + const dossier = dossierDeLongueur(LONGUEUR_CHEMIN_MAX - 1 - longueurRelativeMax('Gala')); + assert.equal(derive('Gala', dossier), 'Gala'); + const options = { papier: 'Lettre', imposition: 'feuille', horodatage: HORODATAGE, reedition: reeditionDe(9999), proposition: 2 ** 31 - 1, ordre: 'table' }; + assert.equal(nomPlanche('Gala', { ...options, dossier: { ...dossier, existants: [] } }), nomPlanche('Gala', options)); + // Un nom qui tient ne se raccourcit pas. + const court = { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, proposition: 3 }; + assert.equal(nomPlanche('Gala', { ...court, dossier: { ...dossier, existants: [] } }), 'Gala.badges-p3_A4_feuille_2031-05-17_20-15-07.pdf'); + }); + test('un domaine hors contrat lève sans rien rendre : base, papier, imposition ou horodatage, TypeError ; réédition vide, de plus de 9 999 badges ou d’une autre forme, RangeError ou TypeError', () => { const valides = { papier: 'A4', imposition: 'feuille', horodatage: HORODATAGE, reedition: null }; const fautes = [ @@ -1127,6 +1304,15 @@ describe('nomPlanche : le nom du fichier de la planche (§ 11.6, § 8.6)', () => [TypeError, BASE, { ...valides, reedition: { ids: 'abc' } }], [RangeError, BASE, { ...valides, reedition: { ids: [] } }], [RangeError, BASE, { ...valides, reedition: reeditionDe(10000) }], + [TypeError, BASE, { ...valides, proposition: 0 }], + [TypeError, BASE, { ...valides, proposition: 1.5 }], + [TypeError, BASE, { ...valides, proposition: '2' }], + [TypeError, BASE, { ...valides, ordre: 'hasard' }], + [TypeError, BASE, { ...valides, ordre: null }], + [TypeError, BASE, { ...valides, dossier: { racine: 'E:\\data', separateur: '\\' } }], + [TypeError, BASE, { ...valides, dossier: { racine: 'E:\\data', separateur: ':', existants: [] } }], + [TypeError, BASE, { ...valides, dossier: { racine: 42, separateur: '/', existants: [] } }], + [TypeError, BASE, { ...valides, dossier: 'E:\\data' }], ]; for (const [type, base, options] of fautes) { assert.throws(() => nomPlanche(base, options), type, `${JSON.stringify(base)} ${JSON.stringify(options).slice(0, 120)}`);