diff --git a/.gitignore b/.gitignore index 8560e62..1c59e72 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,5 @@ desktop.ini /.swarm/ /agentdb.rvf /agentdb.rvf.lock +# Cache et mémoires de Serena (serveur de langage MCP) — propres à un poste. +/.serena/ diff --git a/SUIVI.md b/SUIVI.md index b4ec31d..0a9d205 100644 --- a/SUIVI.md +++ b/SUIVI.md @@ -1659,6 +1659,7 @@ son périmètre. Aucune ne bloque : ce sont des endroits où la documentation d | 4 | `internal/printing/render.go`, le godoc de `Rasterize` | Dit que `weighing_identical` déclare **40 × 25,4 mm, 320 × 203 dots**, et que la règle 3 compare le contenu encré à « la géométrie de l'étiquette existante ». Le gabarit livré déclare **35 × 25 mm, 280 × 200 dots** depuis le banc du 28/07, et la règle compare à la géométrie que la **tête déclare** (ADR-045). §7.3 a été corrigé, le godoc non : c'est du **code**, hors du périmètre du lot de documentation | | 5 | **Deux** godoc, `internal/scale/gramxfoc/gramxfoc.go:14` et `internal/scale/conformance/conformance.go:3` | Répètent tous deux « un paquet, trois fichiers, ~120 lignes dont 70 de tests », mesure d'avant le banc et le corpus par protocole. Le paquet fait **4 fichiers et 693 lignes, dont 536 de tests**, et le branchement au banc va de **34** lignes (`absent`) à **136** (`example`). §9.3 a été corrigé, les deux godoc non, et pour la même raison. Le second a été relevé par le lot voisin, qui ne pouvait pas y toucher non plus | | 6 | ADR-002 et §4 étape 14, la taille d'une étiquette | Annoncent **~16 ko** par étiquette. Le banc du 29/07 a ramené la trame à **14 072 octets** en corrigeant le format de ``. Sans conséquence sur une décision, mais le chiffre circule à trois endroits | +| 7 | `internal/diag/doctor_service.go`, le remède du contrôle « service », et §11.1 la ligne « Journaux texte » | Renvoient vers `C:\ProgramData\OpenScale\data\logs\openscale.log`, qu'**aucun code n'écrit** : le journal technique vit en base (`technical_log`) et sort par le fichier de diagnostic (`technical.csv`) ou la page Journal ; sous Linux, `journalctl -u openscale`. Relevé le 04/09/2026 en cherchant où lire un journal sur un poste de production | *(L'entrée qui portait les trois compteurs d'ADR divergents — `README.md` à 33, `CLAUDE.md` à 41, le dossier à 50 — a été **retirée le 30/07/2026 : elle est réglée**. Les deux points @@ -1729,6 +1730,7 @@ engageantes : | Date | Événement | |---|---| +| 04/09/2026 | **Deux postes en production ont montré des prix vieux de plusieurs jours, avec des étiquettes justes.** Signalé depuis le magasin : le catalogue arrive, l'étiquette d'un produit dont le prix a changé porte le nouveau prix, mais la grille garde l'ancien jusqu'à un F5. Cause lue dans le code et non devinée : l'écran client ne redemandait le catalogue que sur deux signaux du flux d'état, `catalog_count` et `presentation_digest` (§11.2). Un export de nuit aux mêmes produits pesables et à des prix nouveaux fait bien basculer le Hub — d'où l'étiquette juste — et publie un état neuf, mais ne fait bouger ni le compte ni l'empreinte : le navigateur le compare, et garde sa grille. Ni une reconnexion du flux ni le temps ne rechargent le catalogue, seul F5 le fait. Le code connaissait une variante de ce trou — le renommage d'une catégorie, noté « still open » dans `internal/web/catalog.go` — sans voir celle-ci. **Le flux porte désormais `catalog_updated_at`**, l'instant de l'import appliqué, la chaîne même que la barre basse affiche en « Catalogue du … » (ADR-053), et le navigateur recharge quand elle diffère de celle du catalogue qu'il tient : une troisième comparaison, du même mécanisme que l'empreinte de présentation, aucun ADR rouvert. Le renommage de catégorie reste ouvert, nommé au même endroit. Deux bancs rouges avant le correctif : `web/test/session.test.ts` (1 requête reçue là où 2 sont dues) et `TestTheStateStreamNamesTheCatalogInService` (le champ n'existait pas) ; le golden `state.json` bouge d'une ligne. Vérifié dans le conteneur de développement : `make test` vert (vet, passe `-race`, passe sans cgo, boundary, deps), **1 023 tests front sur 38 fichiers**, `svelte-check` et `eslint` à zéro, budget de poids tenu, `internal/web/dist` régénéré. Ce que ce lot laisse : `openscale doctor` nomme un `data\logs\openscale.log` que rien n'écrit, relevé en dérive n° 7 et non corrigé ici. | | 11/08/2026 | **Le premier lancement du poste de développement en conteneur est mort en route, et le lancement suivant a annoncé « Poste prêt » sur un poste qui ne l'était pas.** Le second point est le plus grave des deux, et aucun des deux ne se raconte de lui-même. **(1) La panne.** `dev.sh` s'arrête pendant `postCreateCommand`, sur `npm ci`. Le message n'est ni dans la sortie du script ni dans celle de la CLI, mais dans `~/.npm/_logs/…-debug-0.log` du conteneur : `Error: spawnSync …/web/node_modules/esbuild/bin/esbuild ETXTBSY`, levé par `validateBinaryVersion` (`esbuild/install.js:103`). Le postinstall d'esbuild pose un **lien dur** vers le binaire de plateforme que npm vient d'extraire, puis l'**exécute** — pendant que la phase `reify` de npm peut encore tenir cet inode **ouvert en écriture**, et Linux refuse d'exécuter un fichier ouvert en écriture. C'est une course entre deux morceaux de npm : elle ne dit **rien** du `package-lock.json`, et elle frappe un volume `web/node_modules` **neuf** — le premier lancement d'un contributeur, précisément. **(2) Ce qui la rend collante.** La CLI n'exécute `postCreateCommand` qu'à la **CRÉATION** du conteneur. Le conteneur à moitié préparé reste donc en place, et le `devcontainer up` suivant répond `{"outcome":"success"}` **sans rien préparer** : `dev.sh` annonce alors « Poste prêt » sur un `web/node_modules` vide, et le premier `make front-check` du contributeur échouerait sur un message qui ne parle ni du conteneur ni de sa préparation. **Mesuré, pas déduit** : le relancement de `dev.sh` a fait exactement cela, sortie `success` à l'appui. **Les deux réparations.** `post-create.sh` retente `npm ci` **une seule fois**, et **seulement** si la sortie nomme `ETXTBSY` — tout autre échec, un lock désynchronisé au premier chef, sort à la première tentative, ce qui garde à `ci` le caractère déterministe que §14.1 lui demande. `dev.sh` et `dev.ps1` **nomment la sortie de secours** au moment où `devcontainer up` échoue — `devcontainer up --workspace-folder . --remove-existing-container` —, portée des deux côtés selon la règle de report du 10/08/2026, et `handbook/getting-started.md` la porte aussi pour qui lit le guide avant de se cogner. **Deux bancs, rouges avant le correctif et montrés tels** : `TestThePostCreateScriptRetriesNpmCiOnTheETXTBSYRace` (le script nomme ETXTBSY **et** appelle `npm --prefix web ci` au moins deux fois) et `TestBothDevScriptsSayHowToRedoAFailedPreparation` (les deux scripts nomment `--remove-existing-container`). **Vérification** : les deux branches du correctif exercées avec un faux `npm` — échec ETXTBSY → **sortie 0, 2 appels**, message de reprise imprimé ; échec de lock désynchronisé → **sortie 1, 1 appel, aucune reprise** ; conteneur **et** volume `openscale-node-modules-…` détruits puis `sh dev.sh` rejoué de zéro → **sortie 0** ; `make test` vert dans le conteneur neuf en deux passes, `boundary` et `deps` compris — **3 135 tests verts, 10 écartés, 0 échec** ; `make front-check` → **80 585 o gzip**, 71,5 % du budget de §14.1 ; `go test ./deploy/` vert, `sh -n` compris. **Ce qui n'est PAS fait, et n'est pas à croire fait** : la course **n'a pas été reproduite à la demande** — trois `npm ci` sur un `web/node_modules` vide et un cache npm froid, tous verts —, donc le correctif s'appuie sur le journal de la panne réelle et sur un `npm` bouchonné, pas sur une reproduction ; `dev.ps1` n'a **pas été exécuté**, faute de Windows ici, il n'est qu'analysé par `deploy/powershell_test.go` ; et **rien ne garde** que `--remove-existing-container` existe encore dans une version future de la CLI — l'option est lue dans l'aide de la 0.88.0, elle n'est vérifiée par aucun banc | | 10/08/2026 | **Les deux installeurs marchent désormais en couple, et un banc l'exige.** Décision du propriétaire du produit : `install.ps1` et `install.sh` doivent offrir les **mêmes fonctionnalités d'installation**, et une modification validée sur l'un se reporte sur l'autre, **dans les deux sens**. L'écart de départ était large — `install.sh` n'avait **aucun paramètre**, ne posait ni mot de passe ni identité, ne tirait aucun code de secours, et sa fiche disait encore « à recopier ici à la main ». Il porte maintenant les six mêmes options, les mêmes questions, le même plancher, la même fiche et le même message de fin. **La règle n'est pas gardée par la bonne volonté** : `deploy/parity_test.go` lit le bloc `param()` de l'un et le `case` d'analyse d'arguments de l'autre, et compare les deux ensembles dans les deux sens. La correspondance PascalCase → minuscules-tirets est **calculée** et non listée ; une paire qui y échappe — `-DataRoot` ↔ `--data-dir`, parce que §11.1 sépare `/etc` et `/var/lib` et qu'aucun répertoire Linux n'est une « racine » qui contiendrait les deux — doit porter sa raison. **Une exception est permise, jamais muette** : elle doit être motivée dans le banc **et** dans le script qui n'a pas l'option, et une raison de moins de 60 caractères fait rougir — « pas d'objet » n'est pas un arbitrage. Trois exceptions Windows, vérifiées une par une dans le dépôt et pas supposées : `-AccountPassword` (le compte `openscale` est créé sans mot de passe et avec `/usr/sbin/nologin`), `-SkipAutoLogon` (`openscale-kiosk.service` est `WantedBy=multi-user.target` avec `PAMName=login` : rien à ouvrir, donc rien à sauter — et l'unique usage réel se règle par `systemctl disable`, ce que l'en-tête nomme), `-Pilot` (il existe pour laisser Access relançable, et Access ne tourne pas sur Debian). **Deux pièges mesurés, pas devinés.** (1) Sous `dash`, la longueur de chaîne du shell compte des **OCTETS** quelle que soit la locale : un plancher de quatre appliqué ainsi aurait accepté « éàç » — 3 caractères, 6 octets — que le binaire refuse. La cinquième porte aurait rétabli exactement la divergence que les quatre autres venaient d'éliminer. Elle compte donc des points de code en retirant les octets de continuation UTF-8, `LC_ALL=C` pour ne pas dépendre du `LANG` du poste ; vérifié sur « éàç » et sur quatre emoji. (2) **Le secret ne descend jamais par argv** : `/proc//cmdline` est lisible par **tous** les comptes de la machine, `/proc//environ` seulement par le propriétaire. `bootstrap.sh` fait donc descendre le mot de passe par l'environnement et jamais en argument, et `install.sh` efface la variable **avant le premier processus fils**, sans quoi `apt-get`, `useradd` et `systemctl` en auraient hérité. **La parité a rendu deux écarts que le banc a refusé de corriger lui-même**, et qui l'ont été ensuite : `install.ps1` ne lisait pas `OPENSCALE_ADMIN_PASSWORD` — il n'existait donc **aucune** façon de poser le mot de passe sous Windows sans terminal et sans ligne de commande — et il n'avertissait pas à l'exécution quand `-AdminPassword` arrivait par argv. Les deux sont portés. **Reste un écart NON traité, et il est nommé plutôt que découvert** : les deux *bootstraps* ne sont pas à parité sur leurs options propres — `bootstrap.sh` a `--force` et `--force-install` parce qu'il distingue installation et mise à jour en déléguant à `update.sh`, là où `bootstrap.ps1` lance toujours `install.ps1`. C'est une divergence de **structure** et non un oubli de ce lot ; la trancher demande une quatrième table dans le banc. **Jugé NON BLOQUANT par le propriétaire du produit le 10/08/2026** : la règle de report porte sur les *fonctionnalités d'installation*, que les deux installeurs offrent désormais à l'identique, et ces trois options-là gouvernent le chemin de téléchargement, pas ce qu'un poste devient. Le banc ne les couvre donc pas, et il ne le prétend pas — il déclare son périmètre : installeur → son propre bootstrap. À rouvrir le jour où un geste d'exploitation manque d'un côté. **Vérification** : `sh -n` et le **vrai `dash` 0.5.12 d'une Debian/Ubuntu** sous WSL sur les quatre scripts POSIX ; les fonctions extraites du script livré exécutées pour de bon — analyse d'options, saisie masquée sur un pty, comptage de points de code, relais du secret —, et **chacun des trois bancs éprouvé en le cassant**, huit mutations dont les deux sens de la parité. **Ce qui n'est PAS fait** : `install.sh` n'a **jamais été lancé en entier** — il installe des paquets, crée un compte et pose des unités systemd —, donc rien de ce qui touche `apt-get`, `useradd`, `udev`, `polkit`, `systemctl` ni `/healthz` n'a été exercé ; `uninstall.sh` ne connaît toujours que les emplacements par défaut, si bien qu'un poste installé avec `--install-dir` se désinstalle à la main — `install.sh` le **dit** au journal au moment où il déplace quelque chose, plutôt que de laisser la surprise ; et aucun `busybox sh` n'était disponible pour une troisième vérification syntaxique | | 10/08/2026 | **Une installation de poste de production a buté sur quatre choses le même jour, et deux d'entre elles rendaient le poste inutilisable.** Aucune n'était visible sans installer pour de bon. **(1) Le bouton « Redémarrer le poste » laissait le poste éteint.** Il reste sur « En cours… », l'écran client est noir, et cinq minutes plus tard l'écran annonce que le poste n'a pas répondu. La cause n'est pas dans le code du bouton : `setRecovery` (`internal/platform/service_windows.go`) n'appelait que `SetRecoveryActions`, **jamais** `SetRecoveryActionsOnNonCrashFailures`. Windows met ce drapeau à **faux** par défaut, et faux signifie « n'appliquer les reprises que si le service s'arrête **sans** signaler `SERVICE_STOPPED` ». Or l'arrêt ordonné de §13.4 se termine proprement et le signale : le SCM voyait un arrêt normal et n'appliquait aucune des trois reprises de §15.2. **La prémisse fausse était écrite en quatre endroits** — `failure.go`, `supervised_windows.go`, `internal/web/maintenance.go`, et un banc **VERT** de `cmd/openscale/maintenance_test.go` intitulé « THE CODE IS THE MECHANISM » — plus ADR-055 lui-même, qui affirmait « le SCM applique alors les reprises de §15.2 ». Les cinq sont corrigés ; l'ADR porte l'amendement daté plutôt qu'une réécriture. Le banc neuf n'assertionne pas seulement le plan envoyé au SCM : `setRecovery` prend désormais une interface de deux méthodes, **parce qu'un banc qui ne lirait que le plan resterait vert si quelqu'un retirait le second appel — c'est exactement le défaut qui a été livré**. Vérifié en le cassant : l'appel retiré, deux bancs rougissent. **Décision de périmètre du propriétaire du produit** : les postes déjà installés ne sont **pas** réparés par ce lot — leur configuration a été rattrapée à la main, et seules les prochaines installations doivent fonctionner. `update.ps1` n'est donc pas touché, et `TROUBLESHOOTING.md` dit **sans détour** qu'une mise à jour ne répare pas ce défaut, le réglage fautif vivant dans Windows et non dans le binaire. **(2) `network.listen` vide verrouillait l'administration ENTIÈRE.** Le fichier livré porte littéralement `"listen": ""` — `Export` remet tout le bloc réseau à zéro (`redact.go`), et la cible release exige un export —, donc le poste **refusait sa propre configuration de livraison**. La gravité n'est pas « une faute de plus » : `PUT /admin/api/config` valide le document **entier** et le brouillon du front envoie tout le document quelle que soit la page, si bien qu'une seule faute portant sur un champ qu'**aucun écran n'éditait** empêchait le moindre enregistrement, sur **toutes** les pages. L'opérateur a corrigé `config.json` à la main. `Config.UnmarshalJSON` ramène désormais une adresse vide à celle du profil neutre, comme il le fait déjà pour `update.repository` et `ui.min_products_for_chip` — même mécanisme, même raison. **L'ordre de livraison était contraint et il a été tenu** : la garde d'import de `configTransfer` **d'abord**, le rattrapage du décodeur **ensuite**. Dans l'autre sens, un défaut bloquant serait devenu un défaut **silencieux** — le 422 était le seul garde-fou empêchant un export importé depuis l'écran de déplacer l'écouteur d'un poste de `0.0.0.0:8085` vers la boucle locale, ce qui aurait fermé l'administration au réseau. La page **Poste** gagne enfin les deux champs `network.listen` et `admin_on_lan`, la mécanique de déplacement à chaud (`Rebind`, compte à rebours de 60 s, retour arrière) étant livrée depuis toujours : **seul le champ manquait**, et §14.4 ne le listait pas — c'est de là que venait l'oubli. Le banc nommé `TestCloningAStationShowsTheSAMEEightCharacters` tombait ; il est **réécrit et non supprimé**, son assertion remontée sur le JSON brut de l'export, qui est le niveau où la garantie vit. **(3) Le plancher du mot de passe d'administration descend de 8 à 4**, sur demande du propriétaire du produit. Le chiffre existait en **six exemplaires** — deux en Go, un en TypeScript, un en Svelte, deux en PowerShell — que rien ne reliait ; il est ramené à **une autorité**, `web.MinPasswordLength`, les copies qu'un autre langage impose étant liées à elle par un banc qui **lit le fichier source**. Un banc en Go, qui **analyse l'arbre syntaxique** et non le texte, refuse désormais qu'une longueur de mot de passe soit comparée à un nombre écrit en clair. **Un vrai défaut, indépendant de la valeur, a été trouvé au passage** : la route HTTP comptait des **octets** là où la ligne de commande comptait des **runes** — « é » vaut un caractère au clavier et deux octets sur le fil, donc la route acceptait un secret que le terminal refusait. Les quatre portes comptent maintenant des points de code, PowerShell compris. **Et un piège d'encodage mesuré** : sur une console en `chcp 65001`, un tube vers un processus natif préfixe la marque d'ordre des octets à l'entrée standard, et un mot de passe haché avec elle aurait **muré le poste** ; `readSecretLine` la retire. **(4) L'installation pose maintenant ce qu'elle seule peut savoir** (ADR-060) — mot de passe d'administration, numéro et nom du poste —, et déclare la balance **absente**, faute d'en avoir une de branchée. Nouvelle action `openscale config station`. **Aucune option de `config` ne prend de secret** : le mot de passe passe par l'entrée standard, un argument se lisant dans la liste des processus. **Un piège que la consigne n'avait pas prévu, trouvé par la mesure** : `[Environment]::UserInteractive` **ne suffit pas** à décider qu'on peut poser une question — `Read-Host -AsSecureString` lit la **console** et non le tube, et une installation scriptée avec `UserInteractive` à vrai s'est arrêtée indéfiniment sur la première invite, tuée à 120 s. **Empreinte : `6c06605a` → `79ba8bfc` tant que la balance est déclarée absente, puis `6c06605a` de nouveau** dès qu'elle est redéclarée — mesuré dans les deux sens, et **dit** sur la fiche d'installation, dans le message de fin de l'installeur et dans `INSTALLATION.md`, parce que §15.5 fait comparer les quatre empreintes à l'œil et qu'un écart non annoncé se prend pour une panne. **Ce que la relecture adverse a rattrapé, et qui aurait annulé le bénéfice** : `--no-scale` vide `scale.type`, or **aucun geste de l'écran ne le remettait** — la détection automatique ne faisait que *rendre un rapport*, le protocole se retapant de mémoire dans un volet replié. Les cinq textes qui promettaient « la détection remet la balance en service » étaient donc faux. La détection **servait pourtant déjà le driver reconnu** (`DetectionDTO.driver`), et `cmd/openscale/detect.go` l'écrivait dans son propre commentaire depuis toujours : « *what goes into the form is the driver that recognised what came out of the cable* ». L'écran le jetait. Un bouton **« Utiliser cette balance »** écrit désormais présence, protocole et port d'un seul geste. Au passage, le service simulé du banc de la page Matériel était **malhonnête** — il répondait un driver pour *tous* les ports, y compris muets —, si bien que le banc « un port non reconnu n'offre rien » aurait été vert pour la mauvaise raison. **Deux défauts de l'installeur trouvés par la même relecture** : `if ($StationNumber)` est **faux pour 0** en PowerShell, donc un « 0 » tapé à l'invite était avalé en silence et le journal annonçait quand même « identité posée » ; et l'avertissement « identité NON posée » était **inatteignable** dans le seul cas pour lequel il avait été écrit. Les deux sont reproduits au banc **avant** correction, puis exécutés pour de bon. **Vérification** : `go build ./...`, `go test ./...` en **deux passes** — avec cgo et `-race`, puis sans cgo —, **3 141 tests verts, 12 écartés, 0 échec** sur 35 paquets ; `go vet` et `gofmt` sans remarque ; **1 021 tests web** sur 38 fichiers, `svelte-check` à **0 erreur** ; les six scripts `.ps1` s'analysent sous PowerShell 5.1 **et** 7. Front embarqué reconstruit **une seule fois**, anciens morceaux supprimés du répertoire commité. **Ce qui n'est PAS fait, et n'est pas à croire fait** : **aucun poste réel n'a été installé** avec ce binaire — la chaîne complète `install.ps1` sur une vraie machine, la recette de redémarrage et le comportement observé du bouton restent à éprouver, et la mesure `sc qfailureflag` sur le poste de production n'a **pas** été relevée ; `doctor` **ne contrôle pas** le drapeau de reprise, donc ce défaut resterait invisible sans matériel — c'est le contrôle qui l'aurait attrapé et il n'est pas écrit ; **Linux reste asymétrique**, `deploy/linux/install.sh` ne pose aucune question et ne tire aucun code de secours, ce qui est **dit** dans `INSTALLATION.md` plutôt que laissé croire ; le limiteur de tentatives d'`admin` **ne verrouille jamais** à rythme régulier — son compteur repart à zéro à chaque fenêtre d'une minute, soit ~236 essais/heure —, ce qui est **assumé par écrit** en §14.4 et laissé à un lot séparé pour qu'on puisse mesurer lequel du plancher ou du limiteur a servi. **Un incident d'outillage à retenir** : `TROUBLESHOOTING.md` a été **vidé à zéro octet** par un agent en fin de passe, et c'est la suite de tests qui l'a dit (`TestTheDocumentationIsWrittenForAVolunteer`, cinq symptômes manquants d'un coup) — restauré depuis `HEAD` et la section réécrite. Un banc qui lit la documentation vaut un banc qui lit du code | diff --git a/docs/02-architecture.md b/docs/02-architecture.md index ff14730..c9621d5 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -2856,6 +2856,8 @@ Surcharges : `--config`, `--data`, `OPENSCALE_CONFIG`, `OPENSCALE_DATA`. **Aucun > > **Le compte ne suffit pourtant pas à faire arriver un réglage d'affichage, et c'est ADR-057 qui l'a découvert.** Une présentation qui change **sans** changer le nombre de produits — `show_grid_prices`, `show_by_unit_products`, `grid_columns` — ne déclenchait aucune relecture : on réglait, on enregistrait, et rien ne se passait sur l'écran d'à côté. Le flux d'état porte donc, à côté de `catalog_count`, une **empreinte de présentation** que le navigateur **compare sans jamais la lire**. Elle est prise sur le **DTO de présentation** et non sur la configuration entière : une empreinte globale rechargerait toute la grille au changement de port série ou de noircissement d'impression, pour une donnée que l'écran client ne lit pas. Un champ ajouté demain à la présentation y entre **sans qu'on ait à y penser** ; un bloc qui n'y est pas n'y entrera jamais. L'ETag du catalogue fait qu'une présentation inchangée coûte un 304. > +> **Le compte et l'empreinte ne suffisaient pas non plus à faire arriver un nouveau prix, et c'est la production qui l'a montré (04/09/2026).** Un export de nuit aux mêmes produits pesables et à des prix nouveaux ne fait bouger ni `catalog_count` ni `presentation_digest` : le Hub bascule, l'étiquette porte le nouveau prix, et la grille gardait l'ancien jusqu'au prochain F5 — plusieurs jours, sur deux postes. Le flux porte donc un troisième signal, **`catalog_updated_at`**, l'instant de l'import appliqué — la chaîne même que `GET /api/v1/catalog` sert en `updated_at` et que la barre basse affiche (§14.3, ADR-053). Le navigateur la **compare** à celle du catalogue qu'il tient et ne la lit jamais ; un fichier déjà vu (`unchanged`, §10.5) ne bascule pas, donc ne la fait pas bouger. Ce que le renommage d'une catégorie laisse ouvert est inchangé : il ne bouge aucun des trois signaux. +> > **Le défaut `false` s'applique en silence aux fichiers existants.** La lecture du document n'applique aucun défaut : un fichier écrit avant cette clé se relit comme « masqués ». C'est ce qui est demandé, mais un poste mis à jour perd quinze tuiles sans un message. D'où deux exigences tenues : les fichiers livrés **écrivent la clé** plutôt que de compter sur le zéro du langage — `config-lacagette.json` à `false`, `config-demo.json` à `true`, `NeutralProfile` à `false` —, et la page **Catalogue** annonce combien de produits sont concernés sur le catalogue en service (§14.4). La clé traverse `Export(false)`, donc l'**empreinte de configuration** que les postes d'un parc comparent à l'œil : ajouter cette clé change les huit caractères de tous les postes, même à valeur identique (§11.5, ADR-041). ### 11.3 Validation — un numéro n'est jamais réemployé, tous les contrôles remontés d'un coup diff --git a/internal/web/catalog.go b/internal/web/catalog.go index 506c4ea..277fc15 100644 --- a/internal/web/catalog.go +++ b/internal/web/catalog.go @@ -153,12 +153,13 @@ func presentationOf(ui domain.UIConfig) catalogPresentationDTO { // // # Why it exists at all // -// The browser asks for the catalog again ONLY when catalog_count moves -// (web/src/lib/session.svelte.ts). A presentation that changes without changing the count -// therefore never arrives on the grid -- which is already true of show_grid_prices, and -// with a grid setting would become « on règle, on enregistre, et rien ne se passe sur -// l'écran d'à côté ». The browser only ever COMPARES this string to the previous one; it -// is opaque to it, and the ETag of the catalog makes an unchanged presentation cost a 304. +// The browser asks for the catalog again when catalog_count moves and, since +// 04/09/2026, when catalog_updated_at moves (web/src/lib/session.svelte.ts). A +// presentation that changes without an import therefore never arrives on the grid -- +// which is already true of show_grid_prices, and with a grid setting would become « on +// règle, on enregistre, et rien ne se passe sur l'écran d'à côté ». The browser only ever +// COMPARES this string to the previous one; it is opaque to it, and the ETag of the +// catalog makes an unchanged presentation cost a 304. // // # What it is taken over, because two readings are possible // @@ -326,9 +327,9 @@ func (s *Server) catalogOf(ctx context.Context, catalog *domain.Catalog, cfg dom // // What is still open: this endpoint answers with the new wording the moment it is // asked, but nothing tells a browser already holding a loaded grid to ask again — - // catalog_count and presentation_digest are what trigger that (web/src/lib/ - // session.svelte.ts), and a rename moves neither. A kiosk left running keeps the - // old label until its next catalog load. + // catalog_count, catalog_updated_at and presentation_digest are what trigger that + // (web/src/lib/session.svelte.ts), and a rename moves none of the three. A kiosk + // left running keeps the old label until its next catalog load. for _, c := range cfg.Catalog.Categories { out.Categories = append(out.Categories, categoryDTO{ Code: c.Code, Label: c.Label, Rank: c.Rank, Color: c.Color, diff --git a/internal/web/catalog_test.go b/internal/web/catalog_test.go index e268a2d..78d7120 100644 --- a/internal/web/catalog_test.go +++ b/internal/web/catalog_test.go @@ -210,11 +210,11 @@ func TestAutomaticIsServedAsTheZeroItIsAndNeverByOmission(t *testing.T) { // TestThePresentationDigestReachesTheClientScreen is the reason this digest exists. // -// The browser asks for the catalog again only when `catalog_count` moves -// (web/src/lib/session.svelte.ts). A presentation that changes without changing the -// count therefore never arrives — which is already true of show_grid_prices, and with a -// grid setting would become « on règle, on enregistre, et rien ne se passe sur l'écran -// d'à côté ». The state stream carries a string the browser only ever COMPARES to the +// The browser asks for the catalog again when `catalog_count` or `catalog_updated_at` +// moves (web/src/lib/session.svelte.ts). A presentation that changes without an import +// therefore never arrives — which is already true of show_grid_prices, and with a grid +// setting would become « on règle, on enregistre, et rien ne se passe sur l'écran d'à +// côté ». The state stream carries a string the browser only ever COMPARES to the // previous one, and that string has to move when the setting does. func TestThePresentationDigestReachesTheClientScreen(t *testing.T) { automatic := newBench(t) @@ -232,6 +232,36 @@ func TestThePresentationDigestReachesTheClientScreen(t *testing.T) { } } +// TestTheStateStreamNamesTheCatalogInService is the defect of 04/09/2026, pinned. +// +// A nightly export with the same products at new prices swapped in the Hub, printed +// the new price on every label, and never reached a grid already on screen: the +// browser asked for the catalog again on catalog_count and on presentation_digest only, +// and a re-priced catalog moves neither. Two stations showed prices several days old +// until somebody pressed F5. The stream therefore carries the instant the catalog in +// service was imported — the `updated_at` the grid already shows as « Catalogue du … » +// — and the browser compares it to the one of the catalog it holds. +func TestTheStateStreamNamesTheCatalogInService(t *testing.T) { + yesterday := newBench(t) + page := decodeStatus[catalogDTO](t, yesterday.get("/api/v1/catalog"), http.StatusOK) + before := yesterday.state() + if before.CatalogUpdatedAt == "" || before.CatalogUpdatedAt != page.UpdatedAt { + t.Fatalf("catalog_updated_at du flux = %q, updated_at du catalogue = %q : "+ + "le navigateur n'a rien à comparer", before.CatalogUpdatedAt, page.UpdatedAt) + } + + today := newBench(t, func(o *benchOptions) { o.catalogAt = epoch.Add(24 * time.Hour) }) + after := today.state() + if after.CatalogCount != before.CatalogCount { + t.Fatalf("le compte a bougé (%d → %d) : ce banc doit éprouver un import À COMPTE ÉGAL", + before.CatalogCount, after.CatalogCount) + } + if after.CatalogUpdatedAt == before.CatalogUpdatedAt { + t.Fatalf("instant inchangé (%q) alors qu'un import plus récent est en service : "+ + "l'écran garderait ses anciens prix jusqu'au prochain F5", after.CatalogUpdatedAt) + } +} + // TestThePresentationDigestFollowsThePresentationAndNothingElse is the decision of §3, // checked rather than reviewed. // @@ -372,9 +402,9 @@ func TestTheStationServesEveryWeighableTileWhateverTheGridShows(t *testing.T) { // The snapshot below therefore carries what the last import wrote, and the // configuration carries what somebody has just typed. What THIS endpoint gets is the // second one. What is still open: a browser that already loaded the grid has no -// signal telling it to ask again — a rename moves neither catalog_count nor -// presentation_digest (web/src/lib/session.svelte.ts) — so a kiosk left running keeps -// showing the old label until its next catalog load. +// signal telling it to ask again — a rename moves none of catalog_count, +// catalog_updated_at and presentation_digest (web/src/lib/session.svelte.ts) — so a +// kiosk left running keeps showing the old label until its next catalog load. func TestTheCategoryLabelsFollowTheConfigurationAndNotTheLastImport(t *testing.T) { imported := domain.NewCatalog([]domain.Product{ {ID: "1", Name: "AIL", Reference: "0493021000003", Mode: domain.ByWeight, diff --git a/internal/web/dist/assets/index-9fjLb9_b.js b/internal/web/dist/assets/index-DLsJ3M0Q.js similarity index 59% rename from internal/web/dist/assets/index-9fjLb9_b.js rename to internal/web/dist/assets/index-DLsJ3M0Q.js index d153b2d..b5e2fe2 100644 --- a/internal/web/dist/assets/index-9fjLb9_b.js +++ b/internal/web/dist/assets/index-DLsJ3M0Q.js @@ -1,2 +1,2 @@ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/mount-VqoFW5_m.js","assets/app-BaRSXv80.js","assets/app-Cfxw4Luj.css","assets/mount-DcOlpQNH.css"])))=>i.map(i=>d[i]); -import{e as Ie,u as je,d as re,p as Y,a as q,i as W,I as ee,t as z,b as N,c as w,f as Z,g as E,h as t,s as h,j as ae,k as te,l as se,m as T,n as v,o as u,q as Pe,r as fe,w as De,v as Te,x as ne,y as Ce,R as Re,z as b,A as D,B as qe,T as Le,C as Ne,N as Oe,D as Me,E as We,F as Be,G as Fe,H as ze,M as He,J as Ue,K as Ve,L as Ge,O as Xe,P as Ke,Q as Je,S as Qe,U as Ye,V as Ze,W as $e,X as et}from"./app-BaRSXv80.js";class be{#e=new WeakMap;#t;#n;static entries=new WeakMap;constructor(e){this.#n=e}observe(e,n){var r=this.#e.get(e)||new Set;return r.add(n),this.#e.set(e,r),this.#s().observe(e,this.#n),()=>{var l=this.#e.get(e);l.delete(n),l.size===0&&(this.#e.delete(e),this.#t.unobserve(e))}}#s(){return this.#t??(this.#t=new ResizeObserver(e=>{for(var n of e){be.entries.set(n.target,n);for(var r of this.#e.get(n.target)||[])r(n)}}))}}var tt=new be({box:"border-box"});function he(s,e,n){var r=tt.observe(s,()=>n(s[e]));Ie(()=>(je(()=>n(s[e])),r))}const nt="modulepreload",at=function(s){return"/"+s},ye={},st=function(e,n,r){let l=Promise.resolve();if(n&&n.length>0){let _=function(o){return Promise.all(o.map(i=>Promise.resolve(i).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};document.getElementsByTagName("link");const c=document.querySelector("meta[property=csp-nonce]"),d=c?.nonce||c?.getAttribute("nonce");l=_(n.map(o=>{if(o=at(o),o in ye)return;ye[o]=!0;const i=o.endsWith(".css"),y=i?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${o}"]${y}`))return;const k=document.createElement("link");if(k.rel=i?"stylesheet":nt,i||(k.as="script"),k.crossOrigin="",k.href=o,d&&k.setAttribute("nonce",d),document.head.appendChild(k),i)return new Promise((m,I)=>{k.addEventListener("load",m),k.addEventListener("error",()=>I(new Error(`Unable to preload CSS for ${o}`)))})}))}function f(c){const d=new Event("vite:preloadError",{cancelable:!0});if(d.payload=c,window.dispatchEvent(d),!d.defaultPrevented)throw c}return l.then(c=>{for(const d of c||[])d.status==="rejected"&&f(d.reason);return e().catch(f)})};var rt=E('

kg

',1),it=E('

kg

Poids indisponible

',1),lt=E('');function ot(s,e){Y(e,!0);const n=q(e,"taring",3,!1),r=v(()=>{if(e.linkBanner!=="")return e.linkBanner;const A=e.snapshot?.message??null;if(A!==null)return A.text;switch(e.snapshot?.state){case"product_armed":return"Posez votre produit";case"awaiting_stability":return"Pesée en cours…";case"printing":return"Étiquette en cours…";case"entering_tare":return"Tapez la tare en grammes";case"scale_lost":return"Le poids n’est plus disponible.";default:return"Touchez votre produit, l’étiquette sort"}}),l=v(()=>e.snapshot===null?"var(--waiting)":e.snapshot.state==="faulted"||e.snapshot.state==="scale_lost"?"var(--fault)":e.snapshot.state==="rejected"?"var(--warning)":e.snapshot.state==="succeeded"||e.snapshot.weight.latched?"var(--ready)":"var(--waiting)"),f=v(()=>e.showWeight&&e.snapshot!==null&&e.snapshot.weight.available&&!e.snapshot.weight.expired),c=v(()=>{const A=e.snapshot?.message;return A!=null?A.level:e.snapshot?.state==="faulted"||e.snapshot?.state==="scale_lost"?"error":e.snapshot?.state==="rejected"?"warn":"info"});var d=lt(),_=u(d);let o;var i=u(_);{var y=A=>{var O=rt(),G=Pe(O),X=u(G),K=h(G,2),F=u(K);z(()=>{T(X,e.snapshot.weight.net_text),T(F,`tare ${e.snapshot.weight.tare_g??""} g`)}),w(A,O)},k=A=>{var O=it();w(A,O)};W(i,A=>{t(f)&&e.snapshot?A(y):A(k,-1)})}var m=h(_,2);let I;var g=u(m),P=h(m,2);let B;var R=u(P);ee(R,{name:"tare",size:"1.4em"});var H=h(P,2);let V;z(()=>{ae(d,"data-state",e.snapshot?.state??"initializing"),o=te(_,"",o,{"border-left-color":t(l)}),I=se(m,1,"instruction svelte-4gizvm",null,I,{warn:t(c)==="warn",error:t(c)==="error"}),T(g,t(r)),B=se(P,1,"tare-key touch-target svelte-4gizvm",null,B,{active:n()}),V=te(H,"",V,{background:t(l)})}),N("click",P,function(...A){e.ontare?.apply(this,A)}),w(s,d),Z()}re(["click"]);var ct=E(''),ut=E('');function dt(s,e){Y(e,!0);var n=ut(),r=u(n);fe(r,21,()=>e.chips,d=>d.code,(d,_)=>{var o=ct();let i,y;var k=h(u(o),2),m=u(k),I=h(k,2),g=u(I);z(P=>{i=se(o,1,"chip touch-target svelte-atgty1",null,i,{active:t(_).code===e.active}),ae(o,"aria-pressed",t(_).code===e.active),y=te(o,"",y,P),T(m,t(_).label),T(g,t(_).count)},[()=>({"--chip-ink":Te(t(_).color),"--chip-wash":De(t(_).color)})]),N("click",o,()=>e.onselect(t(_).code)),w(d,o)});var l=h(r,2);let f;var c=u(l);ee(c,{name:"search",size:"1.75rem"}),z(()=>{f=se(l,1,"chip touch-target search-key svelte-atgty1",null,f,{active:e.searchFieldOpen}),ae(l,"aria-pressed",e.searchFieldOpen)}),N("click",l,function(...d){e.onopensearch?.apply(this,d)}),w(s,n),Z()}re(["click"]);var vt=E(''),gt=E('

'),ht=E('

');function we(s,e){Y(e,!0);const n=q(e,"action",3,null);var r=ht(),l=u(r),f=u(l),c=u(f);ee(c,{name:"alert",size:"3rem"});var d=h(f,2),_=u(d),o=h(d,2),i=u(o),y=h(o,2);{var k=g=>{var P=vt(),B=u(P);z(()=>T(B,n().label)),N("click",P,function(...R){n().run?.apply(this,R)}),w(g,P)};W(y,g=>{n()!==null&&g(k)})}var m=h(y,2);{var I=g=>{var P=gt(),B=u(P);z(()=>T(B,e.code)),w(g,P)};W(m,g=>{e.code!==""&&g(I)})}z(()=>{T(_,e.title),T(i,e.detail)}),w(s,r),Z()}re(["click"]);var ft=E('
'),pt=E('

Chargement du catalogue…

',1),_t=E('

'),mt=E('

'),bt=E('
'),yt=E('
'),wt=E('
');function Et(s,e){Y(e,!0);const n=q(e,"gridColumns",3,0),r=q(e,"colors",19,()=>({})),l=q(e,"primaryCode",3,""),f=q(e,"tierAbbrev",19,()=>({})),c=q(e,"selectedID",3,null),d=q(e,"rejectedID",3,null),_=q(e,"busyID",3,null),o=q(e,"showPrices",3,!0),i=q(e,"loading",3,!1),y=q(e,"emptyMessage",3,"Aucun produit ne correspond."),k=q(e,"emptyHint",3,""),m=8,I=26,g="'Inter Variable', system-ui, sans-serif",P="'Inter Variable'",B=16;let R=D(0),H=D(0),V=D(!1);ne(()=>{const x=document.fonts;x!==void 0&&x.load(`700 ${Re}px ${P}`).catch(()=>[]).then(()=>{b(V,!0)})});const A=v(()=>(t(V),Be(g,700)));let O=D(null),G=D(0),X=D(0);const K=v(()=>n()>0?n():t(R)<=0||t(H)<=0?0:Math.max(1,Math.floor((t(R)+m)/(t(H)+m)))),F=v(()=>t(K)>0&&t(R)>0?(t(R)-(t(K)-1)*m)/t(K):0),p=v(()=>n()>0&&t(F)>0&&t(H)>0?t(F)/t(H):1),C=v(()=>We(t(p))),M=v(()=>n()>0?`grid-template-columns: ${Ne(n())}`:void 0),$=v(()=>t(F)>0?t(F)-I:0),ie=v(()=>t(G)>0?t(G):t($));ne(()=>{t(F),t(p),e.products.length;const x=t(O)?.querySelector(".name-box");b(G,x?.clientWidth??0,!0)});const oe=v(()=>{const x=new Map;if(t(A)===null||t(ie)<=0)return x;const L=t(X)>0?t(X):Oe;for(const j of e.products)x.set(j.id,Me(j.name,t(ie),t(A),L,t(C)));return x});var J=wt(),ce=u(J),le=h(ce,2),ue=h(le,2);{var pe=x=>{var L=pt(),j=Pe(L);fe(j,21,()=>({length:B}),qe,(a,S)=>{var U=ft();w(a,U)}),z(()=>te(j,t(M))),he(j,"clientWidth",a=>b(R,a)),w(x,L)},de=x=>{var L=mt(),j=u(L),a=u(j),S=h(j,2);{var U=Q=>{var ge=_t(),_e=u(ge);z(()=>T(_e,k())),w(Q,ge)};W(S,Q=>{k()!==""&&Q(U)})}z(()=>T(a,y())),w(x,L)},ve=x=>{var L=yt();fe(L,21,()=>e.products,j=>j.id,(j,a)=>{var S=bt(),U=u(S);{let Q=v(()=>t(oe).get(t(a).id)??t(C)),ge=v(()=>t(a).id===c()),_e=v(()=>t(a).id===d()),Se=v(()=>t(a).id===_());Le(U,{get product(){return t(a)},get nameSizePx(){return t(Q)},get categoryColor(){return r()[t(a).category_code]},get primaryCode(){return l()},get tierAbbrev(){return f()},get selected(){return t(ge)},get rejected(){return t(_e)},get busy(){return t(Se)},get showPrice(){return o()},get onpick(){return e.onpick}})}w(j,S)}),z(()=>{ae(L,"data-tile-count",e.products.length),te(L,t(M))}),he(L,"clientWidth",j=>b(R,j)),w(x,L)};W(ue,x=>{i()?x(pe):e.products.length===0?x(de,1):x(ve,-1)})}Ce(J,x=>b(O,x),()=>t(O)),z(()=>{ae(J,"data-tile-scale",t(p)),te(J,`--tile-scale: ${t(p)??""}`)}),he(ce,"clientHeight",x=>b(X,x)),he(le,"clientWidth",x=>b(H,x)),w(s,J),Z()}var kt=E(''),xt=E('
');function At(s,e){Y(e,!0);let n=D(null);ne(()=>{t(n)?.focus()});var r=xt(),l=u(r);ee(l,{name:"search",size:"1.75rem"});var f=h(l,2);Ce(f,i=>b(n,i),()=>t(n));var c=h(f,2);{var d=i=>{var y=kt();N("click",y,function(...k){e.onclose?.apply(this,k)}),w(i,y)};W(c,i=>{e.query!==""&&i(d)})}var _=h(c,2),o=u(_);ee(o,{name:"close",size:"1.5rem"}),z(()=>Fe(f,e.query)),N("input",f,i=>e.onquery(i.currentTarget.value)),N("keydown",f,i=>{i.key==="Escape"?(i.preventDefault(),e.onclose()):i.key==="Enter"&&(i.preventDefault(),e.onenter())}),N("click",_,function(...i){e.onclose?.apply(this,i)}),w(s,r),Z()}re(["input","keydown","click"]);function Pt(s){if(s==="")return"";const e=new Date(s);if(Number.isNaN(e.getTime()))return"";const n=l=>String(l).padStart(2,"0");return`${`${n(e.getDate())}/${n(e.getMonth()+1)}/${e.getFullYear()}`} ${n(e.getHours())}:${n(e.getMinutes())}:${n(e.getSeconds())}`}function Ct(s){return s.mode==="by_unit"?`${s.product_name} ${s.quantity} ${s.quantity>1?"unités":"unité"}`:`${s.product_name} ${s.net_text} kg`}var zt=E('Catalogue du '),St=E('Catalogue en attente'),It=E(' '),jt=E('aucune pour le moment'),Dt=E(' '),Tt=E(''),Rt=E('
Dernière étiquette
');function qt(s,e){Y(e,!0);const n=q(e,"catalogAt",3,""),r=q(e,"productCount",3,0),l=q(e,"appVersion",3,""),f=v(()=>[r()>0?`${r()} produits pesables`:"",l()!==""?`application ${l()}`:""].filter(p=>p!=="").join(" · "));var c=Rt();let d;var _=u(c),o=u(_);{var i=p=>{var C=zt(),M=h(u(C)),$=u(M);z(()=>T($,n())),w(p,C)},y=p=>{var C=St();w(p,C)};W(o,p=>{n()!==""?p(i):p(y,-1)})}var k=h(o,2);{var m=p=>{var C=It(),M=u(C);z(()=>T(M,t(f))),w(p,C)};W(k,p=>{t(f)!==""&&p(m)})}var I=h(_,4),g=h(u(I),2);{var P=p=>{var C=jt();w(p,C)},B=p=>{var C=Dt(),M=u(C);z($=>T(M,$),[()=>Ct(e.label)]),w(p,C)};W(g,p=>{e.label===null?p(P):p(B,-1)})}var R=h(I,2);{var H=p=>{var C=Tt();z(()=>C.disabled=!e.available),N("click",C,function(...M){e.onreprint?.apply(this,M)}),w(p,C)};W(R,p=>{e.label!==null&&p(H)})}var V=h(R,2),A=u(V);let O;var G=h(A,2),X=u(G),K=h(V,2),F=u(K);ee(F,{name:"settings",size:"1.75rem"}),z(()=>{d=se(c,1,"bar svelte-161y12f",null,d,{live:e.label!==null}),O=se(A,1,"dot svelte-161y12f",null,O,{fault:!e.healthy}),ae(A,"aria-label",e.healthy?"Matériel disponible":"Matériel indisponible"),T(X,e.healthy?"Balance et imprimante disponibles":"Matériel indisponible")}),N("click",K,function(...p){e.onadmin?.apply(this,p)}),w(s,c),Z()}re(["click"]);var Lt=E(''),Nt=E('

Tare g

');function Ot(s,e){Y(e,!0);const n=["1","2","3","4","5","6","7","8","9","0"];var r=Nt(),l=u(r),f=h(u(l),2),c=u(f),d=h(l,2);fe(d,20,()=>n,m=>m,(m,I)=>{var g=Lt(),P=u(g);z(()=>T(P,I)),N("click",g,()=>e.ondigit(I)),w(m,g)});var _=h(d,2),o=u(_),i=h(o,2),y=h(i,2),k=u(y);ee(k,{name:"check",size:"1.5rem"}),z(()=>T(c,e.grams===""?"0":e.grams)),N("click",o,function(...m){e.onclear?.apply(this,m)}),N("click",i,function(...m){e.oncancel?.apply(this,m)}),N("click",y,function(...m){e.onconfirm?.apply(this,m)}),w(s,r),Z()}re(["click"]);function Mt(s){return s===null||s.state==="initializing"?!1:s.scale.connected&&s.printer.health!=="faulted"}const Ee=1500,Wt=2e3,ke=5e3;function Bt(s,e){return e?s>=ke?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!0}:s>=Wt?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!1}:s>=Ee?{showWeight:!1,banner:"",mustReconnect:!1}:{showWeight:!0,banner:"",mustReconnect:!1}:{showWeight:s=ke}}const Ft=250,Ht={show_grid_prices:!0,idle_timeout_s:45,reprint_window_s:60,sound:!0,show_by_unit_products:!1,grid_columns:0,min_products_for_chip:He};class Ut{#e=D(null);get state(){return t(this.#e)}set state(e){b(this.#e,e,!0)}#t=D(null);get catalog(){return t(this.#t)}set catalog(e){b(this.#t,e,!0)}#n=D(ze({showWeight:!0,banner:"",mustReconnect:!1}));get link(){return t(this.#n)}set link(e){b(this.#n,e,!0)}#s=D("");get catalogError(){return t(this.#s)}set catalogError(e){b(this.#s,e,!0)}#r=null;#i=null;#l=Date.now();#a=!1;#o=!1;get presentation(){return this.catalog?.presentation??Ht}start(){this.#u(),this.#c(),this.#i=setInterval(()=>this.#v(),Ft)}stop(){this.#i!==null&&clearInterval(this.#i),this.#i=null,this.#r?.close(),this.#r=null,this.#a=!1}#c(){const e=new EventSource("/api/v1/stream");this.#r=e,e.onopen=()=>{this.#a=!0,this.#l=Date.now()},e.addEventListener("state",n=>this.#d(n)),e.onerror=()=>{this.#a=!1}}#d(e){this.#l=Date.now(),this.#a=!0;const n=JSON.parse(e.data),r=this.state;if(this.state=n,this.catalog===null)return;const l=n.catalog_count!==this.catalog.product_count,f=r!==null&&n.presentation_digest!==r.presentation_digest;(l||f)&&this.#u()}#v(){const e=Bt(Date.now()-this.#l,this.#a);this.link=e,e.mustReconnect&&(this.#r?.close(),this.#l=Date.now(),this.#c())}async#u(){if(!this.#o){this.#o=!0;try{this.catalog=await Ue(),this.catalogError=""}catch{this.catalog===null&&(this.catalogError="Catalogue indisponible. Prévenez un responsable.")}finally{this.#o=!1}}}}const xe="0123456789ABCDEFGHJKMNPQRSTVWXYZ";function Ae(){let s=Date.now();const e=new Array(26);for(let r=9;r>=0;r--)e[r]=xe[s%32],s=Math.floor(s/32);const n=new Uint8Array(16);crypto.getRandomValues(n);for(let r=0;r<16;r++)e[10+r]=xe[n[r]%32];return e.join("")}var Vt=E('
');function Gt(s,e){Y(e,!0);const n=new Ut;let r=D(ze(Ve)),l=D(""),f=D(!1);const c=v(()=>t(l)!==""||t(f));let d=D(null),_=D(-1),o=D(null);const i=v(()=>n.catalog),y=v(()=>t(i)===null?[]:Qe(t(i))),k=v(()=>t(i)===null?[]:Ke(t(i))),m=v(()=>Xe(t(y),t(r),t(l))),I=v(()=>{const a={};for(const S of t(i)?.categories??[])a[S.code]=S.color;return a}),g=v(()=>n.state),P=v(()=>n.presentation),B=v(()=>Mt(t(g))),R=v(()=>{if(n.catalogError!=="")return{message:n.catalogError,hint:"Le poste réessaie tout seul."};if(t(y).length>0)return{message:"Aucun produit ne correspond.",hint:"Effacez des lettres ou changez de rayon."};if(t(i)!==null&&t(i).products.length>0)return{message:t(i).products.every(U=>U.mode==="by_unit")&&!t(P).show_by_unit_products?"Aucun produit à montrer : ce poste masque les produits vendus à l’unité, et le catalogue reçu ne contient que ceux-là.":"Aucun produit à montrer : les réglages d’affichage de ce poste masquent tout ce qu’il a reçu.",hint:"Le fichier est bien arrivé : prévenez un responsable, c’est un réglage de ce poste qui vide la grille."};const a=t(g)?.station??0;return{message:a>0?`Catalogue vide. En attente du fichier flv_${a}.csv.`:"Catalogue vide. En attente du fichier du poste.",hint:"Prévenez un responsable : aucun produit ne peut être pesé."}}),H=v(()=>t(g)?.state==="rejected"?t(g).product?.id??null:null),V=v(()=>t(g)?.state==="faulted"||t(g)?.state==="out_of_service"),A=["product_armed","weight_present","weight_stable","awaiting_stability","validating","printing"],O=v(()=>t(g)!==null&&A.includes(t(g).state)?t(g).product?.id??null:null),G=v(()=>Pt(t(i)?.updated_at??""));ne(()=>(n.start(),()=>n.stop())),ne(()=>{const a=n.state?.revision??-1;t(d)!==null&&a>t(_)&&(b(d,null),b(_,-1))});async function X(a){if(t(d)===null){b(d,a.id,!0),b(_,n.state?.revision??-1,!0);try{await Ge({product_id:a.id,tare_g:n.state?.weight.tare_g??0,units:1,manual_weight_g:0,seen_weight_g:n.state?.weight.gross_g??0,measurement_seq:n.state?.weight.seq??0,key:Ae()})}catch{b(d,null)}}}async function K(){const a=n.state?.reprint.job_id;a===void 0||a===""||await Je(a,Ae())}function F(){b(l,""),b(f,!1)}function p(){b(f,!0)}function C(){const a=t(m).length===1?t(m)[0]:void 0;a!==void 0&&X(a)}function M(a){if(!(a.metaKey||a.ctrlKey||a.altKey)&&!(a.target instanceof HTMLElement&&a.target.tagName==="INPUT")&&!(t(o)!==null||t(V))){if(a.key==="Escape"){a.preventDefault(),F();return}if(a.key==="Backspace"){a.preventDefault(),b(l,t(l).slice(0,-1),!0);return}if(a.key==="Enter"){C();return}a.key.length===1&&/[a-zA-Z0-9 ]/.test(a.key)&&(a.key===" "&&t(l)===""||(a.preventDefault(),b(l,t(l)+a.key)))}}ne(()=>(window.addEventListener("keydown",M),()=>window.removeEventListener("keydown",M)));async function $(){(await st(()=>import("./mount-VqoFW5_m.js"),__vite__mapDeps([0,1,2,3]))).mountAdmin(document.body)}var ie=Vt(),oe=u(ie);{let a=v(()=>t(o)!==null);ot(oe,{get snapshot(){return t(g)},get showWeight(){return n.link.showWeight},get linkBanner(){return n.link.banner},get taring(){return t(a)},ontare:()=>b(o,"")})}var J=h(oe,2);{var ce=a=>{Ot(a,{get grams(){return t(o)},ondigit:S=>b(o,(t(o)??"")+S),onclear:()=>b(o,""),oncancel:()=>b(o,null),onconfirm:()=>b(o,null)})};W(J,a=>{t(o)!==null&&a(ce)})}var le=h(J,2);{let a=v(()=>t(i)?.pricing.primary_code??""),S=v(()=>Object.fromEntries((t(i)?.pricing.tiers??[]).map(Q=>[Q.code,Q.abbrev]))),U=v(()=>t(i)===null&&n.catalogError==="");Et(le,{get products(){return t(m)},get colors(){return t(I)},get primaryCode(){return t(a)},get tierAbbrev(){return t(S)},get selectedID(){return t(O)},get rejectedID(){return t(H)},get busyID(){return t(d)},get showPrices(){return t(P).show_grid_prices},get gridColumns(){return t(P).grid_columns},get loading(){return t(U)},get emptyMessage(){return t(R).message},get emptyHint(){return t(R).hint},onpick:X})}var ue=h(le,2);{var pe=a=>{At(a,{get query(){return t(l)},onquery:S=>b(l,S,!0),onclose:F,onenter:C})};W(ue,a=>{t(c)&&a(pe)})}var de=h(ue,2);dt(de,{get chips(){return t(k)},get active(){return t(r)},get searchFieldOpen(){return t(c)},onselect:a=>b(r,a,!0),onopensearch:p});var ve=h(de,2);{let a=v(()=>t(g)?.last_label??null),S=v(()=>t(g)?.reprint.available??!1),U=v(()=>t(i)?.app_version??"");qt(ve,{get label(){return t(a)},get available(){return t(S)},get catalogAt(){return t(G)},get productCount(){return t(y).length},get appVersion(){return t(U)},get healthy(){return t(B)},onreprint:K,onadmin:$})}var x=h(ve,2);{var L=a=>{{let S=v(()=>t(g).message?.text??"Prévenez un responsable.");we(a,{title:"Poste indisponible",get detail(){return t(S)},get code(){return t(g).fault_code},action:{label:"J’ai compris",run:()=>{Ye()}}})}},j=a=>{we(a,{title:"Poste hors service",detail:"Ce poste ne peut pas peser. Prévenez un responsable.",get code(){return t(g).fault_code}})};W(x,a=>{t(g)?.state==="faulted"?a(L):t(g)?.state==="out_of_service"&&a(j,1)})}w(s,ie),Z()}const Xt=5,Kt="ResizeObserver loop";function Jt(){let s=!1,e=!1;const n=(c,d)=>{s||(s=!0,$e(c,d),Qt(),setTimeout(()=>location.reload(),Xt*1e3))},r=c=>{e||(e=!0,Ze(c))},l=c=>{if((c.error===void 0||c.error===null)&&c.message.startsWith(Kt)){r(c.message);return}n(c.message,c.error?.stack??"")},f=c=>{n(String(c.reason),c.reason?.stack??"")};return window.addEventListener("error",l),window.addEventListener("unhandledrejection",f),()=>{window.removeEventListener("error",l),window.removeEventListener("unhandledrejection",f)}}function Qt(){const s=document.createElement("div");s.className="fatal",s.append(me("h1","Une erreur est survenue"),me("p","L’écran va se recharger tout seul."),me("p","ERR-UI-01","code")),document.body.appendChild(s)}function me(s,e,n=""){const r=document.createElement(s);return r.textContent=e,n!==""&&(r.className=n),r}Jt();document.addEventListener("contextmenu",s=>{Yt(s.target)||s.preventDefault()});document.addEventListener("dragstart",s=>s.preventDefault());et(Gt,{target:document.getElementById("app")});function Yt(s){return s instanceof Element&&s.closest("[data-admin]")!==null} +import{e as Ie,u as je,d as re,p as Y,a as q,i as W,I as ee,t as z,b as N,c as w,f as Z,g as E,h as t,s as h,j as ae,k as te,l as se,m as T,n as v,o as u,q as Pe,r as fe,w as De,v as Te,x as ne,y as Ce,R as Re,z as b,A as D,B as qe,T as Le,C as Ne,N as Oe,D as Me,E as We,F as Be,G as Fe,H as ze,M as He,J as Ue,K as Ve,L as Ge,O as Xe,P as Ke,Q as Je,S as Qe,U as Ye,V as Ze,W as $e,X as et}from"./app-BaRSXv80.js";class be{#e=new WeakMap;#t;#n;static entries=new WeakMap;constructor(e){this.#n=e}observe(e,n){var r=this.#e.get(e)||new Set;return r.add(n),this.#e.set(e,r),this.#s().observe(e,this.#n),()=>{var l=this.#e.get(e);l.delete(n),l.size===0&&(this.#e.delete(e),this.#t.unobserve(e))}}#s(){return this.#t??(this.#t=new ResizeObserver(e=>{for(var n of e){be.entries.set(n.target,n);for(var r of this.#e.get(n.target)||[])r(n)}}))}}var tt=new be({box:"border-box"});function he(s,e,n){var r=tt.observe(s,()=>n(s[e]));Ie(()=>(je(()=>n(s[e])),r))}const nt="modulepreload",at=function(s){return"/"+s},ye={},st=function(e,n,r){let l=Promise.resolve();if(n&&n.length>0){let _=function(c){return Promise.all(c.map(i=>Promise.resolve(i).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};document.getElementsByTagName("link");const o=document.querySelector("meta[property=csp-nonce]"),d=o?.nonce||o?.getAttribute("nonce");l=_(n.map(c=>{if(c=at(c),c in ye)return;ye[c]=!0;const i=c.endsWith(".css"),y=i?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${c}"]${y}`))return;const k=document.createElement("link");if(k.rel=i?"stylesheet":nt,i||(k.as="script"),k.crossOrigin="",k.href=c,d&&k.setAttribute("nonce",d),document.head.appendChild(k),i)return new Promise((m,I)=>{k.addEventListener("load",m),k.addEventListener("error",()=>I(new Error(`Unable to preload CSS for ${c}`)))})}))}function f(o){const d=new Event("vite:preloadError",{cancelable:!0});if(d.payload=o,window.dispatchEvent(d),!d.defaultPrevented)throw o}return l.then(o=>{for(const d of o||[])d.status==="rejected"&&f(d.reason);return e().catch(f)})};var rt=E('

kg

',1),it=E('

kg

Poids indisponible

',1),lt=E('');function ot(s,e){Y(e,!0);const n=q(e,"taring",3,!1),r=v(()=>{if(e.linkBanner!=="")return e.linkBanner;const A=e.snapshot?.message??null;if(A!==null)return A.text;switch(e.snapshot?.state){case"product_armed":return"Posez votre produit";case"awaiting_stability":return"Pesée en cours…";case"printing":return"Étiquette en cours…";case"entering_tare":return"Tapez la tare en grammes";case"scale_lost":return"Le poids n’est plus disponible.";default:return"Touchez votre produit, l’étiquette sort"}}),l=v(()=>e.snapshot===null?"var(--waiting)":e.snapshot.state==="faulted"||e.snapshot.state==="scale_lost"?"var(--fault)":e.snapshot.state==="rejected"?"var(--warning)":e.snapshot.state==="succeeded"||e.snapshot.weight.latched?"var(--ready)":"var(--waiting)"),f=v(()=>e.showWeight&&e.snapshot!==null&&e.snapshot.weight.available&&!e.snapshot.weight.expired),o=v(()=>{const A=e.snapshot?.message;return A!=null?A.level:e.snapshot?.state==="faulted"||e.snapshot?.state==="scale_lost"?"error":e.snapshot?.state==="rejected"?"warn":"info"});var d=lt(),_=u(d);let c;var i=u(_);{var y=A=>{var O=rt(),G=Pe(O),X=u(G),K=h(G,2),F=u(K);z(()=>{T(X,e.snapshot.weight.net_text),T(F,`tare ${e.snapshot.weight.tare_g??""} g`)}),w(A,O)},k=A=>{var O=it();w(A,O)};W(i,A=>{t(f)&&e.snapshot?A(y):A(k,-1)})}var m=h(_,2);let I;var g=u(m),P=h(m,2);let B;var R=u(P);ee(R,{name:"tare",size:"1.4em"});var H=h(P,2);let V;z(()=>{ae(d,"data-state",e.snapshot?.state??"initializing"),c=te(_,"",c,{"border-left-color":t(l)}),I=se(m,1,"instruction svelte-4gizvm",null,I,{warn:t(o)==="warn",error:t(o)==="error"}),T(g,t(r)),B=se(P,1,"tare-key touch-target svelte-4gizvm",null,B,{active:n()}),V=te(H,"",V,{background:t(l)})}),N("click",P,function(...A){e.ontare?.apply(this,A)}),w(s,d),Z()}re(["click"]);var ct=E(''),ut=E('');function dt(s,e){Y(e,!0);var n=ut(),r=u(n);fe(r,21,()=>e.chips,d=>d.code,(d,_)=>{var c=ct();let i,y;var k=h(u(c),2),m=u(k),I=h(k,2),g=u(I);z(P=>{i=se(c,1,"chip touch-target svelte-atgty1",null,i,{active:t(_).code===e.active}),ae(c,"aria-pressed",t(_).code===e.active),y=te(c,"",y,P),T(m,t(_).label),T(g,t(_).count)},[()=>({"--chip-ink":Te(t(_).color),"--chip-wash":De(t(_).color)})]),N("click",c,()=>e.onselect(t(_).code)),w(d,c)});var l=h(r,2);let f;var o=u(l);ee(o,{name:"search",size:"1.75rem"}),z(()=>{f=se(l,1,"chip touch-target search-key svelte-atgty1",null,f,{active:e.searchFieldOpen}),ae(l,"aria-pressed",e.searchFieldOpen)}),N("click",l,function(...d){e.onopensearch?.apply(this,d)}),w(s,n),Z()}re(["click"]);var vt=E(''),gt=E('

'),ht=E('

');function we(s,e){Y(e,!0);const n=q(e,"action",3,null);var r=ht(),l=u(r),f=u(l),o=u(f);ee(o,{name:"alert",size:"3rem"});var d=h(f,2),_=u(d),c=h(d,2),i=u(c),y=h(c,2);{var k=g=>{var P=vt(),B=u(P);z(()=>T(B,n().label)),N("click",P,function(...R){n().run?.apply(this,R)}),w(g,P)};W(y,g=>{n()!==null&&g(k)})}var m=h(y,2);{var I=g=>{var P=gt(),B=u(P);z(()=>T(B,e.code)),w(g,P)};W(m,g=>{e.code!==""&&g(I)})}z(()=>{T(_,e.title),T(i,e.detail)}),w(s,r),Z()}re(["click"]);var ft=E('
'),pt=E('

Chargement du catalogue…

',1),_t=E('

'),mt=E('

'),bt=E('
'),yt=E('
'),wt=E('
');function Et(s,e){Y(e,!0);const n=q(e,"gridColumns",3,0),r=q(e,"colors",19,()=>({})),l=q(e,"primaryCode",3,""),f=q(e,"tierAbbrev",19,()=>({})),o=q(e,"selectedID",3,null),d=q(e,"rejectedID",3,null),_=q(e,"busyID",3,null),c=q(e,"showPrices",3,!0),i=q(e,"loading",3,!1),y=q(e,"emptyMessage",3,"Aucun produit ne correspond."),k=q(e,"emptyHint",3,""),m=8,I=26,g="'Inter Variable', system-ui, sans-serif",P="'Inter Variable'",B=16;let R=D(0),H=D(0),V=D(!1);ne(()=>{const x=document.fonts;x!==void 0&&x.load(`700 ${Re}px ${P}`).catch(()=>[]).then(()=>{b(V,!0)})});const A=v(()=>(t(V),Be(g,700)));let O=D(null),G=D(0),X=D(0);const K=v(()=>n()>0?n():t(R)<=0||t(H)<=0?0:Math.max(1,Math.floor((t(R)+m)/(t(H)+m)))),F=v(()=>t(K)>0&&t(R)>0?(t(R)-(t(K)-1)*m)/t(K):0),p=v(()=>n()>0&&t(F)>0&&t(H)>0?t(F)/t(H):1),C=v(()=>We(t(p))),M=v(()=>n()>0?`grid-template-columns: ${Ne(n())}`:void 0),$=v(()=>t(F)>0?t(F)-I:0),ie=v(()=>t(G)>0?t(G):t($));ne(()=>{t(F),t(p),e.products.length;const x=t(O)?.querySelector(".name-box");b(G,x?.clientWidth??0,!0)});const oe=v(()=>{const x=new Map;if(t(A)===null||t(ie)<=0)return x;const L=t(X)>0?t(X):Oe;for(const j of e.products)x.set(j.id,Me(j.name,t(ie),t(A),L,t(C)));return x});var J=wt(),ce=u(J),le=h(ce,2),ue=h(le,2);{var pe=x=>{var L=pt(),j=Pe(L);fe(j,21,()=>({length:B}),qe,(a,S)=>{var U=ft();w(a,U)}),z(()=>te(j,t(M))),he(j,"clientWidth",a=>b(R,a)),w(x,L)},de=x=>{var L=mt(),j=u(L),a=u(j),S=h(j,2);{var U=Q=>{var ge=_t(),_e=u(ge);z(()=>T(_e,k())),w(Q,ge)};W(S,Q=>{k()!==""&&Q(U)})}z(()=>T(a,y())),w(x,L)},ve=x=>{var L=yt();fe(L,21,()=>e.products,j=>j.id,(j,a)=>{var S=bt(),U=u(S);{let Q=v(()=>t(oe).get(t(a).id)??t(C)),ge=v(()=>t(a).id===o()),_e=v(()=>t(a).id===d()),Se=v(()=>t(a).id===_());Le(U,{get product(){return t(a)},get nameSizePx(){return t(Q)},get categoryColor(){return r()[t(a).category_code]},get primaryCode(){return l()},get tierAbbrev(){return f()},get selected(){return t(ge)},get rejected(){return t(_e)},get busy(){return t(Se)},get showPrice(){return c()},get onpick(){return e.onpick}})}w(j,S)}),z(()=>{ae(L,"data-tile-count",e.products.length),te(L,t(M))}),he(L,"clientWidth",j=>b(R,j)),w(x,L)};W(ue,x=>{i()?x(pe):e.products.length===0?x(de,1):x(ve,-1)})}Ce(J,x=>b(O,x),()=>t(O)),z(()=>{ae(J,"data-tile-scale",t(p)),te(J,`--tile-scale: ${t(p)??""}`)}),he(ce,"clientHeight",x=>b(X,x)),he(le,"clientWidth",x=>b(H,x)),w(s,J),Z()}var kt=E(''),xt=E('
');function At(s,e){Y(e,!0);let n=D(null);ne(()=>{t(n)?.focus()});var r=xt(),l=u(r);ee(l,{name:"search",size:"1.75rem"});var f=h(l,2);Ce(f,i=>b(n,i),()=>t(n));var o=h(f,2);{var d=i=>{var y=kt();N("click",y,function(...k){e.onclose?.apply(this,k)}),w(i,y)};W(o,i=>{e.query!==""&&i(d)})}var _=h(o,2),c=u(_);ee(c,{name:"close",size:"1.5rem"}),z(()=>Fe(f,e.query)),N("input",f,i=>e.onquery(i.currentTarget.value)),N("keydown",f,i=>{i.key==="Escape"?(i.preventDefault(),e.onclose()):i.key==="Enter"&&(i.preventDefault(),e.onenter())}),N("click",_,function(...i){e.onclose?.apply(this,i)}),w(s,r),Z()}re(["input","keydown","click"]);function Pt(s){if(s==="")return"";const e=new Date(s);if(Number.isNaN(e.getTime()))return"";const n=l=>String(l).padStart(2,"0");return`${`${n(e.getDate())}/${n(e.getMonth()+1)}/${e.getFullYear()}`} ${n(e.getHours())}:${n(e.getMinutes())}:${n(e.getSeconds())}`}function Ct(s){return s.mode==="by_unit"?`${s.product_name} ${s.quantity} ${s.quantity>1?"unités":"unité"}`:`${s.product_name} ${s.net_text} kg`}var zt=E('Catalogue du '),St=E('Catalogue en attente'),It=E(' '),jt=E('aucune pour le moment'),Dt=E(' '),Tt=E(''),Rt=E('
Dernière étiquette
');function qt(s,e){Y(e,!0);const n=q(e,"catalogAt",3,""),r=q(e,"productCount",3,0),l=q(e,"appVersion",3,""),f=v(()=>[r()>0?`${r()} produits pesables`:"",l()!==""?`application ${l()}`:""].filter(p=>p!=="").join(" · "));var o=Rt();let d;var _=u(o),c=u(_);{var i=p=>{var C=zt(),M=h(u(C)),$=u(M);z(()=>T($,n())),w(p,C)},y=p=>{var C=St();w(p,C)};W(c,p=>{n()!==""?p(i):p(y,-1)})}var k=h(c,2);{var m=p=>{var C=It(),M=u(C);z(()=>T(M,t(f))),w(p,C)};W(k,p=>{t(f)!==""&&p(m)})}var I=h(_,4),g=h(u(I),2);{var P=p=>{var C=jt();w(p,C)},B=p=>{var C=Dt(),M=u(C);z($=>T(M,$),[()=>Ct(e.label)]),w(p,C)};W(g,p=>{e.label===null?p(P):p(B,-1)})}var R=h(I,2);{var H=p=>{var C=Tt();z(()=>C.disabled=!e.available),N("click",C,function(...M){e.onreprint?.apply(this,M)}),w(p,C)};W(R,p=>{e.label!==null&&p(H)})}var V=h(R,2),A=u(V);let O;var G=h(A,2),X=u(G),K=h(V,2),F=u(K);ee(F,{name:"settings",size:"1.75rem"}),z(()=>{d=se(o,1,"bar svelte-161y12f",null,d,{live:e.label!==null}),O=se(A,1,"dot svelte-161y12f",null,O,{fault:!e.healthy}),ae(A,"aria-label",e.healthy?"Matériel disponible":"Matériel indisponible"),T(X,e.healthy?"Balance et imprimante disponibles":"Matériel indisponible")}),N("click",K,function(...p){e.onadmin?.apply(this,p)}),w(s,o),Z()}re(["click"]);var Lt=E(''),Nt=E('

Tare g

');function Ot(s,e){Y(e,!0);const n=["1","2","3","4","5","6","7","8","9","0"];var r=Nt(),l=u(r),f=h(u(l),2),o=u(f),d=h(l,2);fe(d,20,()=>n,m=>m,(m,I)=>{var g=Lt(),P=u(g);z(()=>T(P,I)),N("click",g,()=>e.ondigit(I)),w(m,g)});var _=h(d,2),c=u(_),i=h(c,2),y=h(i,2),k=u(y);ee(k,{name:"check",size:"1.5rem"}),z(()=>T(o,e.grams===""?"0":e.grams)),N("click",c,function(...m){e.onclear?.apply(this,m)}),N("click",i,function(...m){e.oncancel?.apply(this,m)}),N("click",y,function(...m){e.onconfirm?.apply(this,m)}),w(s,r),Z()}re(["click"]);function Mt(s){return s===null||s.state==="initializing"?!1:s.scale.connected&&s.printer.health!=="faulted"}const Ee=1500,Wt=2e3,ke=5e3;function Bt(s,e){return e?s>=ke?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!0}:s>=Wt?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!1}:s>=Ee?{showWeight:!1,banner:"",mustReconnect:!1}:{showWeight:!0,banner:"",mustReconnect:!1}:{showWeight:s=ke}}const Ft=250,Ht={show_grid_prices:!0,idle_timeout_s:45,reprint_window_s:60,sound:!0,show_by_unit_products:!1,grid_columns:0,min_products_for_chip:He};class Ut{#e=D(null);get state(){return t(this.#e)}set state(e){b(this.#e,e,!0)}#t=D(null);get catalog(){return t(this.#t)}set catalog(e){b(this.#t,e,!0)}#n=D(ze({showWeight:!0,banner:"",mustReconnect:!1}));get link(){return t(this.#n)}set link(e){b(this.#n,e,!0)}#s=D("");get catalogError(){return t(this.#s)}set catalogError(e){b(this.#s,e,!0)}#r=null;#i=null;#l=Date.now();#a=!1;#o=!1;get presentation(){return this.catalog?.presentation??Ht}start(){this.#u(),this.#c(),this.#i=setInterval(()=>this.#v(),Ft)}stop(){this.#i!==null&&clearInterval(this.#i),this.#i=null,this.#r?.close(),this.#r=null,this.#a=!1}#c(){const e=new EventSource("/api/v1/stream");this.#r=e,e.onopen=()=>{this.#a=!0,this.#l=Date.now()},e.addEventListener("state",n=>this.#d(n)),e.onerror=()=>{this.#a=!1}}#d(e){this.#l=Date.now(),this.#a=!0;const n=JSON.parse(e.data),r=this.state;if(this.state=n,this.catalog===null)return;const l=n.catalog_count!==this.catalog.product_count,f=n.catalog_updated_at!==this.catalog.updated_at,o=r!==null&&n.presentation_digest!==r.presentation_digest;(l||f||o)&&this.#u()}#v(){const e=Bt(Date.now()-this.#l,this.#a);this.link=e,e.mustReconnect&&(this.#r?.close(),this.#l=Date.now(),this.#c())}async#u(){if(!this.#o){this.#o=!0;try{this.catalog=await Ue(),this.catalogError=""}catch{this.catalog===null&&(this.catalogError="Catalogue indisponible. Prévenez un responsable.")}finally{this.#o=!1}}}}const xe="0123456789ABCDEFGHJKMNPQRSTVWXYZ";function Ae(){let s=Date.now();const e=new Array(26);for(let r=9;r>=0;r--)e[r]=xe[s%32],s=Math.floor(s/32);const n=new Uint8Array(16);crypto.getRandomValues(n);for(let r=0;r<16;r++)e[10+r]=xe[n[r]%32];return e.join("")}var Vt=E('
');function Gt(s,e){Y(e,!0);const n=new Ut;let r=D(ze(Ve)),l=D(""),f=D(!1);const o=v(()=>t(l)!==""||t(f));let d=D(null),_=D(-1),c=D(null);const i=v(()=>n.catalog),y=v(()=>t(i)===null?[]:Qe(t(i))),k=v(()=>t(i)===null?[]:Ke(t(i))),m=v(()=>Xe(t(y),t(r),t(l))),I=v(()=>{const a={};for(const S of t(i)?.categories??[])a[S.code]=S.color;return a}),g=v(()=>n.state),P=v(()=>n.presentation),B=v(()=>Mt(t(g))),R=v(()=>{if(n.catalogError!=="")return{message:n.catalogError,hint:"Le poste réessaie tout seul."};if(t(y).length>0)return{message:"Aucun produit ne correspond.",hint:"Effacez des lettres ou changez de rayon."};if(t(i)!==null&&t(i).products.length>0)return{message:t(i).products.every(U=>U.mode==="by_unit")&&!t(P).show_by_unit_products?"Aucun produit à montrer : ce poste masque les produits vendus à l’unité, et le catalogue reçu ne contient que ceux-là.":"Aucun produit à montrer : les réglages d’affichage de ce poste masquent tout ce qu’il a reçu.",hint:"Le fichier est bien arrivé : prévenez un responsable, c’est un réglage de ce poste qui vide la grille."};const a=t(g)?.station??0;return{message:a>0?`Catalogue vide. En attente du fichier flv_${a}.csv.`:"Catalogue vide. En attente du fichier du poste.",hint:"Prévenez un responsable : aucun produit ne peut être pesé."}}),H=v(()=>t(g)?.state==="rejected"?t(g).product?.id??null:null),V=v(()=>t(g)?.state==="faulted"||t(g)?.state==="out_of_service"),A=["product_armed","weight_present","weight_stable","awaiting_stability","validating","printing"],O=v(()=>t(g)!==null&&A.includes(t(g).state)?t(g).product?.id??null:null),G=v(()=>Pt(t(i)?.updated_at??""));ne(()=>(n.start(),()=>n.stop())),ne(()=>{const a=n.state?.revision??-1;t(d)!==null&&a>t(_)&&(b(d,null),b(_,-1))});async function X(a){if(t(d)===null){b(d,a.id,!0),b(_,n.state?.revision??-1,!0);try{await Ge({product_id:a.id,tare_g:n.state?.weight.tare_g??0,units:1,manual_weight_g:0,seen_weight_g:n.state?.weight.gross_g??0,measurement_seq:n.state?.weight.seq??0,key:Ae()})}catch{b(d,null)}}}async function K(){const a=n.state?.reprint.job_id;a===void 0||a===""||await Je(a,Ae())}function F(){b(l,""),b(f,!1)}function p(){b(f,!0)}function C(){const a=t(m).length===1?t(m)[0]:void 0;a!==void 0&&X(a)}function M(a){if(!(a.metaKey||a.ctrlKey||a.altKey)&&!(a.target instanceof HTMLElement&&a.target.tagName==="INPUT")&&!(t(c)!==null||t(V))){if(a.key==="Escape"){a.preventDefault(),F();return}if(a.key==="Backspace"){a.preventDefault(),b(l,t(l).slice(0,-1),!0);return}if(a.key==="Enter"){C();return}a.key.length===1&&/[a-zA-Z0-9 ]/.test(a.key)&&(a.key===" "&&t(l)===""||(a.preventDefault(),b(l,t(l)+a.key)))}}ne(()=>(window.addEventListener("keydown",M),()=>window.removeEventListener("keydown",M)));async function $(){(await st(()=>import("./mount-VqoFW5_m.js"),__vite__mapDeps([0,1,2,3]))).mountAdmin(document.body)}var ie=Vt(),oe=u(ie);{let a=v(()=>t(c)!==null);ot(oe,{get snapshot(){return t(g)},get showWeight(){return n.link.showWeight},get linkBanner(){return n.link.banner},get taring(){return t(a)},ontare:()=>b(c,"")})}var J=h(oe,2);{var ce=a=>{Ot(a,{get grams(){return t(c)},ondigit:S=>b(c,(t(c)??"")+S),onclear:()=>b(c,""),oncancel:()=>b(c,null),onconfirm:()=>b(c,null)})};W(J,a=>{t(c)!==null&&a(ce)})}var le=h(J,2);{let a=v(()=>t(i)?.pricing.primary_code??""),S=v(()=>Object.fromEntries((t(i)?.pricing.tiers??[]).map(Q=>[Q.code,Q.abbrev]))),U=v(()=>t(i)===null&&n.catalogError==="");Et(le,{get products(){return t(m)},get colors(){return t(I)},get primaryCode(){return t(a)},get tierAbbrev(){return t(S)},get selectedID(){return t(O)},get rejectedID(){return t(H)},get busyID(){return t(d)},get showPrices(){return t(P).show_grid_prices},get gridColumns(){return t(P).grid_columns},get loading(){return t(U)},get emptyMessage(){return t(R).message},get emptyHint(){return t(R).hint},onpick:X})}var ue=h(le,2);{var pe=a=>{At(a,{get query(){return t(l)},onquery:S=>b(l,S,!0),onclose:F,onenter:C})};W(ue,a=>{t(o)&&a(pe)})}var de=h(ue,2);dt(de,{get chips(){return t(k)},get active(){return t(r)},get searchFieldOpen(){return t(o)},onselect:a=>b(r,a,!0),onopensearch:p});var ve=h(de,2);{let a=v(()=>t(g)?.last_label??null),S=v(()=>t(g)?.reprint.available??!1),U=v(()=>t(i)?.app_version??"");qt(ve,{get label(){return t(a)},get available(){return t(S)},get catalogAt(){return t(G)},get productCount(){return t(y).length},get appVersion(){return t(U)},get healthy(){return t(B)},onreprint:K,onadmin:$})}var x=h(ve,2);{var L=a=>{{let S=v(()=>t(g).message?.text??"Prévenez un responsable.");we(a,{title:"Poste indisponible",get detail(){return t(S)},get code(){return t(g).fault_code},action:{label:"J’ai compris",run:()=>{Ye()}}})}},j=a=>{we(a,{title:"Poste hors service",detail:"Ce poste ne peut pas peser. Prévenez un responsable.",get code(){return t(g).fault_code}})};W(x,a=>{t(g)?.state==="faulted"?a(L):t(g)?.state==="out_of_service"&&a(j,1)})}w(s,ie),Z()}const Xt=5,Kt="ResizeObserver loop";function Jt(){let s=!1,e=!1;const n=(o,d)=>{s||(s=!0,$e(o,d),Qt(),setTimeout(()=>location.reload(),Xt*1e3))},r=o=>{e||(e=!0,Ze(o))},l=o=>{if((o.error===void 0||o.error===null)&&o.message.startsWith(Kt)){r(o.message);return}n(o.message,o.error?.stack??"")},f=o=>{n(String(o.reason),o.reason?.stack??"")};return window.addEventListener("error",l),window.addEventListener("unhandledrejection",f),()=>{window.removeEventListener("error",l),window.removeEventListener("unhandledrejection",f)}}function Qt(){const s=document.createElement("div");s.className="fatal",s.append(me("h1","Une erreur est survenue"),me("p","L’écran va se recharger tout seul."),me("p","ERR-UI-01","code")),document.body.appendChild(s)}function me(s,e,n=""){const r=document.createElement(s);return r.textContent=e,n!==""&&(r.className=n),r}Jt();document.addEventListener("contextmenu",s=>{Yt(s.target)||s.preventDefault()});document.addEventListener("dragstart",s=>s.preventDefault());et(Gt,{target:document.getElementById("app")});function Yt(s){return s instanceof Element&&s.closest("[data-admin]")!==null} diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index a5bb210..f677983 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -6,7 +6,7 @@ Pesée - + diff --git a/internal/web/dto.go b/internal/web/dto.go index 816fcd1..73ac02f 100644 --- a/internal/web/dto.go +++ b/internal/web/dto.go @@ -74,11 +74,19 @@ type stateDTO struct { // GET /api/v1/catalog, which the browser keeps: a snapshot at 10 Hz has no // business carrying 355 products. CatalogCount int `json:"catalog_count"` + // CatalogUpdatedAt is when the catalog in service was IMPORTED, RFC 3339 -- the same + // string GET /api/v1/catalog serves as updated_at, so that the browser can compare + // the two and never has to parse either. It exists because the count cannot tell + // two catalogs apart: a nightly export with the same products at new prices moved + // neither CatalogCount nor PresentationDigest, and two stations showed prices + // several days old until somebody pressed F5 (04/09/2026). Empty when no import has + // ever applied one, like updated_at. + CatalogUpdatedAt string `json:"catalog_updated_at"` // PresentationDigest moves when, and only when, the screen settings served with the // catalog move. It rides next to CatalogCount because it answers the same question - // -- « faut-il redemander le catalogue ? » -- for the half of that payload no count - // can speak for. The browser COMPARES it and never reads it; presentationDigest in - // catalog.go owns what goes into it and why. + // -- « faut-il redemander le catalogue ? » -- for the half of that payload neither + // the count nor the import instant can speak for. The browser COMPARES it and never + // reads it; presentationDigest in catalog.go owns what goes into it and why. PresentationDigest string `json:"presentation_digest"` // UnloggedCount is the counter of ADR-013: labels that came out and could not be // journalled. A red light on the dashboard, never a refusal. @@ -248,6 +256,10 @@ func (s *Server) stateOf(snap station.Snapshot) stateDTO { Degraded: degradationOf(snap.Degraded), CatalogCount: snap.Catalog.WeighableCount(), UnloggedCount: snap.UnloggedWeighings, + // Read from the Hub and not from the snapshot, like the digest below: the Hub + // stamps the import that produced the catalog it swaps in (§10.8), and a + // snapshot describes a plate, a product and a printer -- never an import. + CatalogUpdatedAt: rfc3339OrEmpty(s.hub.CatalogUpdatedAt()), // Read from the Hub and not from the snapshot: the presentation is a // configuration that reloads hot (§11.4), and a snapshot describes a plate, a // product and a printer -- never a screen setting. diff --git a/internal/web/harness_test.go b/internal/web/harness_test.go index 91a2196..6c5cafa 100644 --- a/internal/web/harness_test.go +++ b/internal/web/harness_test.go @@ -81,10 +81,14 @@ type bench struct { // clock is filled BEFORE the tweaks run, so that a test wiring a collaborator of its // own — a Binder, typically — gives it the same injected clock as the station. type benchOptions struct { - clock *fake.Clock - binder *Binder - config func(*domain.Config) - catalog *domain.Catalog + clock *fake.Clock + binder *Binder + config func(*domain.Config) + catalog *domain.Catalog + // catalogAt is when that catalog was IMPORTED — the instant the composition root + // reads back from the imports table. The bench's own epoch unless a case moves it, + // which is what a case about « the same products at new prices » has to do. + catalogAt time.Time noStore bool assets fs.FS images fs.FS @@ -164,8 +168,8 @@ func newBench(t *testing.T, tweak ...func(*benchOptions)) *bench { t.Helper() clock := fake.NewClock(epoch) - o := benchOptions{clock: clock, catalog: garlicCatalog(), images: fstest.MapFS{}, - catalogSources: shippedCatalogSources()} + o := benchOptions{clock: clock, catalog: garlicCatalog(), catalogAt: epoch, + images: fstest.MapFS{}, catalogSources: shippedCatalogSources()} for _, f := range tweak { f(&o) } @@ -183,10 +187,11 @@ func newBench(t *testing.T, tweak ...func(*benchOptions)) *bench { st, err := station.New(station.Options{ Clock: clock, Config: cfg, Catalog: o.catalog, - // The instant the composition root reads back from the imports table — here, the - // bench's own epoch. A station stamps the catalog it STARTS with, and it takes - // that instant from the store precisely so that a restart cannot invent one. - CatalogAt: epoch, + // The instant the composition root reads back from the imports table — the + // bench's own epoch unless the case says otherwise. A station stamps the catalog + // it STARTS with, and it takes that instant from the store precisely so that a + // restart cannot invent one. + CatalogAt: o.catalogAt, Scale: b.scale, Printer: b.printer, Journal: b.store, TechnicalSink: b.store, // The rollback puts the FILE back as well as the running station, and it is the diff --git a/internal/web/testdata/state.json b/internal/web/testdata/state.json index 005280c..d5edac7 100644 --- a/internal/web/testdata/state.json +++ b/internal/web/testdata/state.json @@ -136,6 +136,7 @@ "reason": "Le port de la balance ne peut pas être ouvert." }, "catalog_count": 1, + "catalog_updated_at": "2026-07-27T08:06:48Z", "presentation_digest": "c217a4b4", "unlogged_weighings_count": 3 } diff --git a/web/src/lib/dto.ts b/web/src/lib/dto.ts index 08a2beb..d08ff25 100644 --- a/web/src/lib/dto.ts +++ b/web/src/lib/dto.ts @@ -170,6 +170,14 @@ export interface StateDTO { degraded: DegradationDTO | null /** Combien de tuiles la grille porte — les tuiles, elles, viennent du catalogue. */ catalog_count: number + /** + * Quand le catalogue en service a été importé, RFC 3339 — la même chaîne que + * `updated_at` du catalogue, pour se comparer à lui sans être lue. Le compte ne + * distingue pas deux catalogues : un export de nuit aux mêmes produits et à des + * prix nouveaux ne le faisait pas bouger, et la grille gardait ses anciens prix + * jusqu'au prochain F5 (04/09/2026). Vide tant qu'aucun import n'a été appliqué. + */ + catalog_updated_at: string /** * L'empreinte des réglages d'écran, OPAQUE : elle se compare, elle ne se lit pas. * diff --git a/web/src/lib/session.svelte.ts b/web/src/lib/session.svelte.ts index b0bd7bd..13dcc16 100644 --- a/web/src/lib/session.svelte.ts +++ b/web/src/lib/session.svelte.ts @@ -91,13 +91,17 @@ export class Session { /** * Enregistre un état, et redemande le catalogue quand ce qu'il porte a bougé. * - * DEUX raisons de le redemander, et pas une seule. Le nombre de tuiles se compare - * au catalogue chargé ; les réglages d'écran, eux, se comparent à l'état PRÉCÉDENT, - * parce que l'empreinte voyage dans le flux et non dans le catalogue. Sans cette - * seconde comparaison, un exploitant change le nombre de colonnes, enregistre, et - * rien ne se passe sur l'écran d'à côté — la conclusion « ce réglage ne fait rien » - * contre laquelle le contrôle 46 d'ADR-031 avait été écrit. C'est déjà vrai de - * `show_grid_prices`, invisible aujourd'hui, et réparé au passage. + * TROIS raisons de le redemander, et pas une seule. Le nombre de tuiles et + * l'instant d'import se comparent au catalogue chargé ; les réglages d'écran, eux, + * se comparent à l'état PRÉCÉDENT, parce que l'empreinte voyage dans le flux et non + * dans le catalogue. Sans cette comparaison-là, un exploitant change le nombre de + * colonnes, enregistre, et rien ne se passe sur l'écran d'à côté — la conclusion + * « ce réglage ne fait rien » contre laquelle le contrôle 46 d'ADR-031 avait été + * écrit. C'est déjà vrai de `show_grid_prices`, invisible aujourd'hui, et réparé au + * passage. Sans l'instant d'import, un export de nuit aux mêmes produits et à des + * prix nouveaux ne faisait bouger ni le compte ni l'empreinte : le Hub basculait, + * l'étiquette portait le nouveau prix, et la grille gardait l'ancien jusqu'au + * prochain F5 — plusieurs jours en magasin, sur deux postes (04/09/2026). */ #receive(event: MessageEvent): void { this.#lastMessageAt = Date.now() @@ -111,11 +115,14 @@ export class Session { // validée par ETag, donc un catalogue inchangé coûte un 304 — et une présentation // inchangée, jamais de requête du tout. const countMoved = next.catalog_count !== this.catalog.product_count + // Deux chaînes RFC 3339 écrites par le même code Go : elles se comparent, elles + // ne se lisent pas. + const importMoved = next.catalog_updated_at !== this.catalog.updated_at // Le premier état reçu n'a pas de précédent, et il n'en a pas besoin : `start()` // vient de charger le catalogue, donc sa présentation est déjà celle du poste. const presentationMoved = previous !== null && next.presentation_digest !== previous.presentation_digest - if (countMoved || presentationMoved) { + if (countMoved || importMoved || presentationMoved) { void this.#loadCatalog() } } diff --git a/web/test/fixtures/health.ts b/web/test/fixtures/health.ts index b20344a..bc13a66 100644 --- a/web/test/fixtures/health.ts +++ b/web/test/fixtures/health.ts @@ -98,6 +98,9 @@ export function nominalState(overrides: Partial = {}): StateDTO { }, degraded: null, catalog_count: 331, + // Figé, comme l'empreinte ci-dessous : seul son CHANGEMENT a un sens, et + // `session.test.ts` est le banc qui le fait bouger. + catalog_updated_at: '2026-07-27T08:06:48Z', // Opaque, et figée : seul son CHANGEMENT a un sens, et aucun de ces bancs n'en // décrit un. `session.test.ts` est celui qui la fait bouger. presentation_digest: 'p1', diff --git a/web/test/screen.test.ts b/web/test/screen.test.ts index 8f7078b..0cbeb8e 100644 --- a/web/test/screen.test.ts +++ b/web/test/screen.test.ts @@ -107,6 +107,9 @@ function restingState(overrides: Partial = {}): StateDTO { printer: { health: 'ready', detail: '', pending_jobs_count: 0, observed_at: '' }, degraded: null, catalog_count: catalog.product_count, + // Celui du catalogue servi, pour la même raison que l'empreinte ci-dessous : un + // instant qui différerait ferait redemander le catalogue à chaque état poussé. + catalog_updated_at: catalog.updated_at, // Une chaîne opaque : ce qu'elle vaut n'a aucun sens ici, seul son CHANGEMENT // en a un. Figée pour que les cas qui poussent plusieurs états ne redemandent // pas le catalogue sans le vouloir. diff --git a/web/test/session.test.ts b/web/test/session.test.ts index 109fda2..393d571 100644 --- a/web/test/session.test.ts +++ b/web/test/session.test.ts @@ -7,11 +7,14 @@ import { catalogFromExport } from './fixtures/odoo' /** * Ce qui décide qu'un écran client redemande son catalogue. * - * Deux choses le décident, et une seule était branchée. Le nombre de tuiles bouge - * quand un import passe ; les réglages d'écran, eux, bougent quand un exploitant - * enregistre une page d'administration — et jusqu'ici l'écran d'à côté n'en savait - * rien. « On change le réglage, on enregistre, et rien ne se passe » est exactement - * la conclusion contre laquelle le contrôle 46 d'ADR-031 avait été écrit. + * Trois choses le décident, et une seule était branchée au départ. Le nombre de + * tuiles bouge quand un import ajoute ou retire des produits ; les réglages d'écran, + * eux, bougent quand un exploitant enregistre une page d'administration — et + * jusqu'ici l'écran d'à côté n'en savait rien. « On change le réglage, on enregistre, + * et rien ne se passe » est exactement la conclusion contre laquelle le contrôle 46 + * d'ADR-031 avait été écrit. Puis l'instant d'import, branché le 04/09/2026 : un + * export aux mêmes produits et à des prix nouveaux ne fait bouger ni le compte ni + * l'empreinte, et la grille gardait ses anciens prix jusqu'au prochain F5. * * L'empreinte est OPAQUE : ce banc ne lui donne jamais de sens, il ne fait que la * faire changer. C'est aussi tout ce que le navigateur en fait. @@ -55,13 +58,22 @@ class FakeEventSource { } /** - * Un état réduit aux deux champs dont dépend la relecture. + * Un état réduit aux trois champs dont dépend la relecture. * * `#receive` ne lit que ceux-là ; un DTO complet ferait croire que les autres - * pèsent sur la décision, et c'est précisément ce qu'on vérifie ici. + * pèsent sur la décision, et c'est précisément ce qu'on vérifie ici. L'instant + * d'import vaut celui du catalogue servi, sauf pour le cas qui le fait bouger. */ -function stateWith(catalogCount: number, digest: string): StateDTO { - return { catalog_count: catalogCount, presentation_digest: digest } as StateDTO +function stateWith( + catalogCount: number, + digest: string, + catalogUpdatedAt = catalog.updated_at, +): StateDTO { + return { + catalog_count: catalogCount, + presentation_digest: digest, + catalog_updated_at: catalogUpdatedAt, + } as StateDTO } let session: Session | null = null @@ -151,6 +163,28 @@ describe('la relecture du catalogue sur changement de présentation', () => { }) }) +describe('la relecture du catalogue sur un nouvel import', () => { + it('le redemande quand l’instant d’import bouge, à compte ET empreinte égaux', async () => { + // Le défaut du 04/09/2026 : un export de nuit change des prix sans changer le + // nombre de produits pesables. Le Hub bascule, l'étiquette porte le nouveau + // prix, et la grille gardait l'ancien jusqu'au prochain F5 — plusieurs jours + // en magasin, sur deux postes. + const open = await opened() + const count = open.catalog?.product_count ?? 0 + await push(stateWith(count, 'p1'), 1) + served = { ...catalog, updated_at: '2026-09-04T04:12:00Z' } + await push(stateWith(count, 'p1', '2026-09-04T04:12:00Z'), 2) + expect(open.catalog?.updated_at).toBe('2026-09-04T04:12:00Z') + }) + + it('ne redemande rien tant que l’instant d’import est celui du catalogue chargé', async () => { + const open = await opened() + const count = open.catalog?.product_count ?? 0 + await push(stateWith(count, 'p1', catalog.updated_at), 1) + await push(stateWith(count, 'p1', catalog.updated_at), 1) + }) +}) + describe('les réglages d’écran par défaut, tant qu’aucun catalogue n’est arrivé', () => { it('laisse la grille en automatique plutôt que de figer un nombre de colonnes', () => { expect(new Session().presentation.grid_columns).toBe(0)